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
+49 -36
View File
@@ -9,6 +9,9 @@
# type - "select" (only type currently supported)
# default - default value (int or str determines how it's parsed from the form)
# options - list of {value, label} dicts
#
# "poll" refreshes the cell every monitor poll interval; "poll_seconds"
# overrides that cadence for a widget whose data moves slowly.
from __future__ import annotations
WIDGET_REGISTRY: dict[str, dict] = {
@@ -60,7 +63,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
"status_overview": {
"key": "status_overview",
"label": "Overview",
"description": "At-a-glance summary — host count, monitor up/down/pending, and alerts",
"description": "What needs attention across Steward: hosts, monitors and firing alerts, problems listed with since-times",
"hx_url": "/status/widget",
"detail_url": "/status/",
"plugin": None,
@@ -70,7 +73,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
"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",
"description": "One row per host: monitor status, uptime, and agent CPU, memory, disk and load, linking to the host page",
"hx_url": "/hosts/overview/widget",
"detail_url": "/hosts/",
"plugin": None,
@@ -84,7 +87,9 @@ WIDGET_REGISTRY: dict[str, dict] = {
"hx_url": "/hosts/uptime/widget",
"detail_url": "/hosts/uptime",
"plugin": None,
"poll": False,
"poll": True,
# Uptime moves slowly and reads 30 days of checks: every 5 minutes, not every poll.
"poll_seconds": 300,
"params": [],
},
"traefik_routers": {
@@ -184,29 +189,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
},
],
},
"unifi_devices": {
"key": "unifi_devices",
"label": "UniFi — Devices",
"description": "Network infrastructure devices with online/offline state",
"hx_url": "/plugins/unifi/widget/devices",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
"params": [
{
"key": "type_filter",
"label": "Device type",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All devices"},
{"value": "wireless", "label": "Wireless APs"},
{"value": "wired", "label": "Wired only"},
{"value": "offline", "label": "Offline devices"},
],
},
],
},
"docker_containers": {
"key": "docker_containers",
"label": "Docker — Services & failures",
@@ -260,16 +242,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
"poll": True,
"params": [],
},
"host_resources": {
"key": "host_resources",
"label": "Host Agent — Resources",
"description": "Fleet view: CPU, memory, disk, and load per host — each with a trend sparkline",
"hx_url": "/plugins/host_agent/widget",
"detail_url": "/hosts/",
"plugin": "host_agent",
"poll": True,
"params": [],
},
"host_resource_history": {
"key": "host_resource_history",
"label": "Host Agent — History Graph",
@@ -301,6 +273,47 @@ WIDGET_REGISTRY: dict[str, dict] = {
},
}
# Widgets that were merged into another (#5213): migration 0032 moved every
# placement of the old key onto the new one, so nothing here renders them.
RETIRED = {
"host_resources": "hosts_overview", # Hosts — Overview carries cpu/mem/disk/load per host
"unifi_devices": "unifi_overview", # UniFi — Overview lists the offline devices
}
# The recommended dashboard (#5213): what needs attention first, then the
# fleet, then each integration. (key, width, height, config) on the 12-column
# grid, one cell 70px tall; a widget whose plugin is off is skipped and the
# rest close up. Seeded into the first dashboard and offered by
# "Reset to the recommended layout" in edit mode.
RECOMMENDED: list[tuple[str, int, int, dict]] = [
("status_overview", 12, 3, {}),
("hosts_overview", 6, 7, {}),
("monitors", 6, 7, {"show_down_only": "no", "limit": 10}),
("docker_containers", 6, 6, {}),
("docker_resources", 6, 6, {"limit": 10}),
("uptime_summary", 6, 5, {}),
("traefik_routers", 6, 5, {}),
("unifi_overview", 6, 4, {}),
("snmp_devices", 6, 4, {}),
("traefik_request_chart", 12, 4, {}),
]
def recommended_layout(plugins_cfg: dict) -> list[dict]:
"""RECOMMENDED placed on the grid for what is enabled: left to right,
a new row when the next one does not fit, each row as tall as its
tallest widget. [{key, x, y, w, h, config}]."""
available = {w["key"] for w in get_available_widgets(plugins_cfg)}
out, x, y, row_h = [], 0, 0, 0
for key, w, h, config in RECOMMENDED:
if key not in available:
continue
if x + w > 12:
x, y, row_h = 0, y + row_h, 0
out.append({"key": key, "x": x, "y": y, "w": w, "h": h, "config": config})
x, row_h = x + w, max(row_h, h)
return out
# Group each widget for the picker (Core monitors / Monitoring capabilities /
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
+31 -8
View File
@@ -11,7 +11,7 @@ from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
from steward.core.settings import public_base_url
from steward.core.widgets import get_available_widgets, WIDGET_REGISTRY
from steward.core.widgets import get_available_widgets, recommended_layout, WIDGET_REGISTRY
dashboard_bp = Blueprint("dashboard", __name__)
@@ -92,16 +92,20 @@ async def _get_or_create_system_default(db) -> Dashboard:
dash = Dashboard(name="Default", owner_id=None, is_default=True, is_shared=True)
db.add(dash)
await db.flush()
plugins_cfg = current_app.config.get("PLUGINS", {})
# Seed a 3-up grid (each widget 4 cols wide x 4 cells tall on the 12-col grid).
for pos, w in enumerate(get_available_widgets(plugins_cfg)):
db.add(DashboardWidget(
dashboard_id=dash.id, widget_key=w["key"],
grid_x=(pos % 3) * 4, grid_y=(pos // 3) * 4, grid_w=4, grid_h=4,
))
_seed_recommended(db, dash.id)
return dash
def _seed_recommended(db, dash_id: int) -> None:
"""Place the recommended layout (widgets.RECOMMENDED) on a dashboard."""
for item in recommended_layout(current_app.config.get("PLUGINS", {})):
db.add(DashboardWidget(
dashboard_id=dash_id, widget_key=item["key"],
grid_x=item["x"], grid_y=item["y"], grid_w=item["w"], grid_h=item["h"],
config_json=json.dumps(item["config"]) if item["config"] else None,
))
def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
plugins_cfg = current_app.config.get("PLUGINS", {})
out = []
@@ -384,6 +388,25 @@ async def remove_widget(dash_id: int, widget_id: int):
return ("", 204)
@dashboard_bp.post("/d/<int:dash_id>/edit/reset")
@require_role(UserRole.viewer)
async def reset_layout(dash_id: int):
"""Replace a dashboard's widgets with the recommended layout (#5213). The
edit drawer asks first: widgets added by hand and their titles go."""
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
dash = (await db.execute(select(Dashboard).where(Dashboard.id == dash_id))).scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
for w in await _get_widgets(db, dash_id):
await db.delete(w)
await db.flush()
_seed_recommended(db, dash_id)
return redirect(f"/d/{dash_id}")
@dashboard_bp.post("/d/<int:dash_id>/edit/layout")
@require_role(UserRole.viewer)
async def save_layout(dash_id: int):
+2 -2
View File
@@ -16,7 +16,7 @@ from steward.models.monitors import Monitor, MonitorResult
# Host-level agent metrics read for the at-a-glance views (plus the root
# mount's disk_used_pct, read from the "<host>:/" sub-resource).
AGENT_GLANCE_METRICS = ("cpu_pct", "mem_used_pct", "disk_used_pct", "disk_used_pct_worst")
AGENT_GLANCE_METRICS = ("cpu_pct", "mem_used_pct", "disk_used_pct", "disk_used_pct_worst", "load_1m")
async def check_name_free(db, name: str, exclude_id: str | None = None) -> None:
@@ -172,7 +172,7 @@ async def agent_overview_by_host(
) -> dict[str, dict]:
"""Per-host agent glance (cpu/mem/disk + freshness) from plugin_metric_latest.
{host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root,
{host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root, load_1m,
last_at, fresh}} for each host with any agent samples.
Core-safe (no host_agent import). Freshness compares the latest host-level
@@ -0,0 +1,52 @@
"""Merge duplicate dashboard widgets into the one that covers both (#5213)
Revision ID: 0032_retire_duplicate_widgets
Revises: 0031_ansible_run_origin
Create Date: 2026-10-07
Host Agent — Resources (host_resources) folds into Hosts — Overview
(hosts_overview), and UniFi — Devices (unifi_devices) into UniFi — Overview
(unifi_overview); widgets.RETIRED names the pairs. A dashboard that already
shows the surviving widget loses the retired placement; any other keeps its
cell, now showing the survivor. The old widget's own options don't apply to
the new one, so they are cleared.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0032_retire_duplicate_widgets"
down_revision: Union[str, None] = "0031_ansible_run_origin"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Frozen here rather than imported: a migration keeps meaning what it meant.
_PAIRS = (("host_resources", "hosts_overview"), ("unifi_devices", "unifi_overview"))
def upgrade() -> None:
for old, new in _PAIRS:
op.execute(sa.text("""
DELETE FROM dashboard_widgets d
WHERE d.widget_key = :old
AND EXISTS (SELECT 1 FROM dashboard_widgets k
WHERE k.dashboard_id = d.dashboard_id AND k.widget_key = :new)
""").bindparams(old=old, new=new))
# Two placements of the old widget on one dashboard: keep the first.
op.execute(sa.text("""
DELETE FROM dashboard_widgets d
WHERE d.widget_key = :old
AND EXISTS (SELECT 1 FROM dashboard_widgets k
WHERE k.dashboard_id = d.dashboard_id AND k.widget_key = :old
AND k.id < d.id)
""").bindparams(old=old))
op.execute(sa.text("""
UPDATE dashboard_widgets SET widget_key = :new, config_json = NULL
WHERE widget_key = :old
""").bindparams(old=old, new=new))
def downgrade() -> None:
# The merge is one-way: which survivor cells were once the retired widget
# isn't recorded, and the retired widgets no longer exist to render.
pass
+15 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import func, select
from steward.alerts.service import active_alerts
from steward.alerts.service import active_alerts, alert_label
from steward.auth.middleware import require_role
from steward.core.status import collect_status, sparkline_svg
from steward.models.hosts import Host
@@ -53,10 +53,23 @@ async def widget():
entries = await collect_status(db)
total_hosts = (await db.execute(
select(func.count()).select_from(Host))).scalar()
alerts = await active_alerts(db)
up, down, pending = _summarize(entries)
return await render_template(
"status/widget.html",
entries=entries, up=up, down=down, pending=pending,
total_hosts=total_hosts,
total_hosts=total_hosts, alerts=overview_alerts(alerts),
widget_id=request.args.get("wid", "0"),
)
def overview_alerts(alerts) -> list[dict]:
"""The Overview's alert lines: breaching or silent first, then those
someone has taken; each {rule, label, since}."""
order = {"firing": 0, "no_data": 1, "pending": 2, "acknowledged": 3}
rows = []
for state, rule in alerts:
label = alert_label(state)
since = state.no_data_since if label == "no_data" else state.last_transition_at
rows.append({"rule": rule, "label": label, "since": since})
return sorted(rows, key=lambda r: order.get(r["label"], 9))
+1 -1
View File
@@ -20,7 +20,7 @@
</div>
<div class="dash-cell-body" id="widget-{{ item.db.id }}"
hx-get="{{ item.hx_url }}"
hx-trigger="load, retry{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-trigger="load, retry{% if item.defn.poll %}, every {{ item.defn.get('poll_seconds') or poll_interval }}s{% endif %}"
hx-request='{"timeout": 20000}'
data-poll="{{ '1' if item.defn.poll else '0' }}"
hx-swap="innerHTML"><div class="cell-state">Loading…</div></div>
@@ -5,7 +5,14 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;
position:sticky;top:0;background:var(--bg-card);padding-bottom:0.5rem;">
<div class="section-title" style="margin-bottom:0;">Add a widget</div>
<button type="button" class="btn btn-sm" id="dash-edit-done">Done</button>
<span style="display:flex;gap:0.5rem;align-items:center;">
<form method="post" action="/d/{{ dashboard.id }}/edit/reset" style="margin:0;"
data-confirm="Replace every widget on this dashboard with the recommended layout? Widgets you added or renamed are removed.">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm"
title="Overview first, then hosts and monitors, then each integration">Reset to the recommended layout</button>
</form>
<button type="button" class="btn btn-sm" id="dash-edit-done">Done</button>
</span>
</div>
{% if addable %}
{% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %}
+1 -1
View File
@@ -51,7 +51,7 @@
{# Pass share token as &s= param so require_role allows through #}
<div class="dash-cell-body" id="widget-{{ gw.id }}"
hx-get="{{ item.hx_url }}&s={{ raw_token }}"
hx-trigger="load, retry{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-trigger="load, retry{% if item.defn.poll %}, every {{ item.defn.get('poll_seconds') or poll_interval }}s{% endif %}"
hx-request='{"timeout": 20000}'
data-poll="{{ '1' if item.defn.poll else '0' }}"
hx-swap="innerHTML"><div class="cell-state">Loading…</div></div>
@@ -34,6 +34,7 @@
{% if a.get('cpu_pct') is not none %}<span title="CPU — value + last-hour trend" style="display:inline-flex;align-items:center;gap:0.3rem;{{ threshold_style(a.cpu_pct, 'cpu') }}"><span style="color:var(--text-dim);">cpu</span> {{ "%.0f"|format(a.cpu_pct) }}%{% if spark %}<span style="line-height:0;opacity:0.85;">{{ spark | safe }}</span>{% endif %}</span>{% endif %}
{% if a.get('mem_used_pct') is not none %}<span title="Memory" style="{{ threshold_style(a.mem_used_pct, 'mem') }}"><span style="color:var(--text-dim);">mem</span> {{ "%.0f"|format(a.mem_used_pct) }}%</span>{% endif %}
{% if a.get('disk_root') is not none %}<span title="Root filesystem (/)" style="{{ threshold_style(a.disk_root, 'disk') }}"><span style="color:var(--text-dim);">disk /</span> {{ "%.0f"|format(a.disk_root) }}%</span>{% endif %}
{% if a.get('load_1m') is not none %}<span title="Load average over 1 minute: processes running or waiting to run"><span style="color:var(--text-dim);">load</span> {{ "%.2f"|format(a.load_1m) }}</span>{% endif %}
{% if not a.fresh %}<span title="Agent data is stale" style="color:var(--yellow);">stale</span>{% endif %}
{% else %}
<span style="color:var(--text-dim);" title="No agent reporting">no agent</span>
+41 -17
View File
@@ -1,28 +1,48 @@
{# status/widget.html — the unified Overview: host count + monitor health
(up/down/pending) + alerts link + problem list. Replaced the old fixed
top summary strip. #}
<div style="display:flex;flex-wrap:wrap;gap:1.25rem;align-items:baseline;margin-bottom:0.6rem;">
{# status/widget.html — the dashboard Overview (#5213): everything that needs
attention, across hosts, monitors and alerts, with since-times. The Monitors
widget is the full list of checks; this is the summary. #}
{% from "_macros.html" import status_badge %}
{% set firing = alerts | selectattr("label", "in", ["firing", "no_data"]) | list %}
<div style="display:flex;flex-wrap:wrap;gap:0.4rem 1.5rem;align-items:baseline;margin-bottom:0.6rem;">
<span>
{% if session.user_id %}<a href="/hosts/" style="color:inherit;text-decoration:none;">{% endif %}
<span class="stat-val" style="font-size:1.5rem;">{{ total_hosts }}</span><span class="stat-label">host{{ '' if total_hosts == 1 else 's' }}</span>
{% if session.user_id %}</a>{% endif %}
</span>
<span><span class="stat-val" style="font-size:1.5rem;color:var(--green);">{{ up }}</span><span class="stat-label">up</span></span>
<span><span class="stat-val" style="font-size:1.5rem;color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span><span class="stat-label">down</span></span>
<span><span class="stat-val" style="font-size:1.5rem;color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span><span class="stat-label">pending</span></span>
{% if session.user_id %}<a href="/alerts/" style="margin-left:auto;align-self:center;font-size:0.8rem;">Alerts →</a>{% endif %}
<span title="Uptime monitors by their latest check">
<span class="stat-label" style="margin-right:0.35rem;">Monitors</span>
<span class="stat-val tone-ok" style="font-size:1.5rem;">{{ up }}</span><span class="stat-label">up</span>
<span class="stat-val {{ 'tone-crit' if down else 'tone-dim' }}" style="font-size:1.5rem;margin-left:0.6rem;">{{ down }}</span><span class="stat-label">down</span>
{% if pending %}<span class="stat-val tone-warn" style="font-size:1.5rem;margin-left:0.6rem;">{{ pending }}</span><span class="stat-label">pending</span>{% endif %}
</span>
<span title="Alert rules firing or gone silent">
{% if session.user_id %}<a href="/alerts/" style="color:inherit;text-decoration:none;">{% endif %}
<span class="stat-label" style="margin-right:0.35rem;">Alerts</span>
<span class="stat-val {{ 'tone-crit' if firing else 'tone-dim' }}" style="font-size:1.5rem;">{{ firing|length }}</span><span class="stat-label">firing</span>
{% if session.user_id %}</a>{% endif %}
</span>
</div>
{% set problems = entries | rejectattr("status", "equalto", "up") | list %}
{% if not entries %}
<p class="empty" style="padding:0.3rem 0;font-size:0.85rem;">No monitors configured.</p>
{% elif not problems %}
<div style="color:var(--green);font-size:0.85rem;padding:0.3rem 0;">✓ All {{ up }} monitors are up.</div>
{% if not problems and not alerts %}
<div class="tone-ok" style="font-size:0.85rem;padding:0.3rem 0;">
{% if entries %}✓ All {{ up }} monitors are up and no alert is firing.{% else %}No monitors configured, and no alert is firing.{% endif %}
</div>
{% else %}
<div style="display:grid;gap:1px;">
{% for a in alerts[:6] %}
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.2rem 0;font-size:0.82rem;">
{{ status_badge(a.label) }}
{% set nm_style = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" %}
{% if session.user_id %}
<a href="/alerts/history?rule={{ a.rule.id }}" title="{{ a.rule.name }}" style="{{ nm_style }}color:inherit;text-decoration:none;">{{ a.rule.name }}</a>
{% else %}<span title="{{ a.rule.name }}" style="{{ nm_style }}">{{ a.rule.name }}</span>{% endif %}
<span style="color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">{{ a.since|ago }}</span>
</div>
{% endfor %}
{% for e in problems[:8] %}
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.2rem 0;font-size:0.82rem;">
<span class="dot {% if e.status == 'down' %}dot-down{% else %}dot-dim{% endif %}"></span>
{{ status_badge(e.status) }}
{% set nm_style = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" %}
{# Link to the monitor's own detail view for logged-in users; plain text on the public share view. #}
{% if session.user_id and e.detail_url %}
@@ -30,12 +50,16 @@
{% else %}
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:500;">{{ e.status }}</span>
<span style="color:var(--text-dim);font-size:0.7rem;">{{ e.kind|monitor_type }}</span>
{% if e.since %}<span style="color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">{% if e.since_at_least %}over {% endif %}{{ e.since|ago }}</span>{% endif %}
</div>
{% endfor %}
{% if problems|length > 8 %}
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">+{{ problems|length - 8 }} more…</div>
{% if alerts|length > 6 or problems|length > 8 %}
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">
{% if alerts|length > 6 %}+{{ alerts|length - 6 }} more alerts{% endif %}
{% if problems|length > 8 %}{% if alerts|length > 6 %} · {% endif %}+{{ problems|length - 8 }} more monitors{% endif %}
{% if session.user_id %} — <a href="/status/">Status</a>{% endif %}
</div>
{% endif %}
</div>
{% endif %}