feat(dashboard): a curated default layout, merged duplicate widgets, a labelled Overview (#5213)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 50s
CI / unit (push) Failing after 1m16s
CI / publish (push) Skipped

- New installs start from widgets.RECOMMENDED, packed row by row for the
  enabled plugins; the edit drawer can reset a dashboard to it.
- Host Agent — Resources folds into Hosts — Overview (which gains load 1m),
  UniFi — Devices into UniFi — Overview; migration 0032 moves existing
  dashboards onto the survivors.
- The Overview labels what it counts (hosts, monitors up/down/pending,
  alerts firing) and lists firing alerts and down monitors with since-times.
- Uptime polls every 5 minutes (poll_seconds) instead of every cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:42:04 -04:00
co-authored by Claude Opus 5.5
parent 5ec930bf76
commit 5492336f1e
18 changed files with 381 additions and 309 deletions
+1 -103
View File
@@ -17,7 +17,7 @@ from sqlalchemy import select, or_, and_
from datetime import timedelta
from steward.core.formatting import worst_reading
from steward.core.metric_series import latest_by_resource, points as metric_points
from steward.core.metric_series import points as metric_points
from steward.core.settings import get_setting, public_base_url
from steward.core.time_range import parse_range, RANGE_OPTIONS
from steward.models.hosts import Host
@@ -477,108 +477,6 @@ def _stale_after_seconds() -> int:
)
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 {}
host_names = [h.name for h in hosts.values()]
latest_by = await latest_by_resource(
session, SOURCE_MODULE, host_names + [n + ":/" for n in host_names])
latest: dict[str, dict[str, float]] = {}
root_disk: dict[str, float] = {} # host_name → root (/) disk %
for resource, metrics in latest_by.items():
if resource.endswith(":/"):
if "disk_used_pct" in metrics:
root_disk[resource[:-2]] = metrics["disk_used_pct"].value
continue
latest[resource] = {m: r.value for m, r in metrics.items()}
# Per-host recent series for the inline sparklines (cpu/mem/load host-level,
# disk from the root mount), so each metric shows its trend beside the value —
# the host-panel at-a-glance, on the fleet widget. 1h window keeps it cheap.
spark_series: dict[str, dict[str, list]] = {
n: {"cpu": [], "mem": [], "disk": [], "load": []} for n in host_names
}
if host_names:
since = datetime.now(timezone.utc) - timedelta(hours=1)
sp_rows = (await session.execute(
select(PluginMetric).where(
PluginMetric.source_module == SOURCE_MODULE,
PluginMetric.recorded_at >= since,
or_(
and_(PluginMetric.resource_name.in_(host_names),
PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct", "load_1m"))),
and_(PluginMetric.resource_name.in_([n + ":/" for n in host_names]),
PluginMetric.metric_name == "disk_used_pct"),
),
).order_by(PluginMetric.recorded_at)
)).scalars().all()
for r in sp_rows:
if r.resource_name.endswith(":/"):
name = r.resource_name[:-2]
if name in spark_series:
spark_series[name]["disk"].append(r.value)
else:
key = {"cpu_pct": "cpu", "mem_used_pct": "mem", "load_1m": "load"}.get(r.metric_name)
if key and r.resource_name in spark_series:
spark_series[r.resource_name][key].append(r.value)
from steward.core.status import sparkline_svg
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
ser = spark_series.get(host.name, {})
sparks = {
k: (sparkline_svg(v[-60:], width=72, height=16) if len(v) >= 2 else "")
for k, v in ser.items()
}
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"), # health dot only
"disk_root": root_disk.get(host.name), # displayed at-a-glance
"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"),
"disk_read_bps": m.get("disk_read_bps"),
"disk_write_bps": m.get("disk_write_bps"),
"sparks": sparks,
})
rows.sort(key=lambda r: r["host"].name.lower())
return rows
@host_agent_bp.get("/widget")
@require_role(UserRole.viewer)
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)
@host_agent_bp.get("/widget/history")
@require_role(UserRole.viewer)
async def widget_history():