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 -2
View File
@@ -157,7 +157,6 @@ plugins/host_agent/
│ └── host_agent_001_initial.py # creates host_agent_registrations table
├── templates/
│ ├── detail.html # per-host detail page
│ ├── widget_table.html # dashboard fleet table widget
│ ├── widget_history.html # dashboard history chart widget
│ ├── settings_list.html # plugin settings: list of registered hosts
│ └── install.sh.j2 # install script template (Jinja)
@@ -438,7 +437,7 @@ echo "Logs: journalctl -u steward-agent -f"
### Dashboard widgets (`steward/core/widgets.py`)
- **`host_resources`** — table widget. One row per monitored host: name, CPU %, mem %, disk % (worst mount), load 1m, "last seen Xs ago" with red/yellow/green coloring. Fleet glance.
- The fleet glance (CPU, mem, disk /, load 1m, stale mark) is the core **Hosts — Overview** widget (`hosts_overview`); the old `host_resources` table was folded into it (#5213).
- **`host_resource_history`** — chart widget for one host. CPU / mem / disk over a selectable time range (1h, 6h, 24h, 7d). Same pattern as every other history widget — Chart.js.
### Per-host detail page
+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():
@@ -1,69 +0,0 @@
{# host_agent widget — fleet glance. One horizontal row per host, zebra-striped
so adjacent hosts read as distinct groupings: health dot + host name on the
left, metric cells (cpu/mem/disk/load) each with a trend sparkline laid out
horizontally on the right. The name wraps (never hard-truncated) but the row
stays horizontal. #}
{% if rows %}
{% macro metric_cell(label, value, fmt, svg, style="", title="") %}
<div style="min-width:62px;flex:0 0 auto;" title="{{ title }}">
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
<div style="font-weight:500;font-size:0.8rem;font-variant-numeric:tabular-nums;{{ style }}">
{% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %}
</div>
<div style="line-height:0;height:16px;">{{ svg | safe }}</div>
</div>
{% endmacro %}
{# Two-line down/up (or read/write) throughput cell. #}
{% macro io_cell(label, down_sym, down_val, up_sym, up_val, title="") %}
<div style="min-width:80px;flex:0 0 auto;" title="{{ title }}">
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
<div style="font-size:0.72rem;font-variant-numeric:tabular-nums;line-height:1.3;">
<div>{{ down_sym }} {{ down_val|rate }}</div>
<div>{{ up_sym }} {{ up_val|rate }}</div>
</div>
</div>
{% endmacro %}
<div class="host-fleet">
{% for r in rows %}
{% set stale = r.stale %}
<div style="display:flex;align-items:center;gap:0.6rem 1rem;flex-wrap:wrap;padding:0.5rem 0.6rem;border-radius:4px;{% if loop.index0 % 2 %}background:var(--bg-elevated);{% endif %}">
{# Left — health dot + full host name (wraps, never truncated) + last seen #}
<div style="display:flex;align-items:center;gap:0.5rem;flex:1 1 150px;min-width:120px;">
<span class="dot {% if stale %}dot-dim{% elif (r.cpu_pct or 0) >= 90 or (r.mem_used_pct or 0) >= 90 or (r.disk_worst or 0) >= 90 %}dot-warn{% else %}dot-up{% endif %}"
style="flex-shrink:0;"
title="{% if stale %}stale / no recent data{% else %}warns at CPU/memory ≥90% or any disk mount ≥90% (the 'disk /' figure shows root only){% endif %}"></span>
<span style="min-width:0;">
{% if session.user_id %}
<a href="/hosts/{{ r.host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</a>
{% else %}
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
{% endif %}
<span style="display:block;font-size:0.7rem;color:var(--text-dim);">
{% if r.reg.last_seen_at %}seen {{ r.reg.last_seen_at|ago }}{% else %}never seen{% endif %}
</span>
</span>
</div>
{# Right — metric cells with value + trend, laid out horizontally #}
<div style="display:flex;flex-wrap:wrap;gap:0.4rem 1rem;justify-content:flex-end;">
{{ metric_cell("cpu", r.cpu_pct, "%.0f%%", r.sparks.cpu, threshold_style(r.cpu_pct, 'cpu'), "Average CPU utilization") }}
{{ metric_cell("mem", r.mem_used_pct, "%.0f%%", r.sparks.mem, threshold_style(r.mem_used_pct, 'mem'), "Memory in use") }}
{{ metric_cell("disk /", r.disk_root, "%.0f%%", r.sparks.disk, threshold_style(r.disk_root, 'disk'), "Root filesystem (/) usage") }}
{{ metric_cell("load", r.load_1m, "%.2f", r.sparks.load, "", "Load average (1m)") }}
{# Extra agent metrics — shown only when the host reports them (VMs/containers
often have no temp sensor, etc.) so absent data doesn't clutter the row. #}
{% if r.net_rx_bps is not none or r.net_tx_bps is not none %}
{{ io_cell("net", "↓", r.net_rx_bps, "↑", r.net_tx_bps, "Network throughput (down / up)") }}
{% endif %}
{% if r.disk_read_bps is not none or r.disk_write_bps is not none %}
{{ io_cell("disk i/o", "rd", r.disk_read_bps, "wr", r.disk_write_bps, "Disk I/O (read / write)") }}
{% endif %}
{% if r.temp_max is not none %}
{{ metric_cell("temp", r.temp_max, "%.0f°C", "", threshold_style(r.temp_max, 'temp'), "Hottest sensor") }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No hosts with agent data yet.</p>
{% endif %}
-30
View File
@@ -324,33 +324,3 @@ async def widget_clients():
snapshot=snapshot,
widget_id=widget_id,
)
@unifi_bp.get("/widget/devices")
@require_role(UserRole.viewer)
async def widget_devices():
"""HTMX dashboard widget: infrastructure devices with optional type filter."""
type_filter = request.args.get("type_filter", "all")
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name)
)
all_devices = result.scalars().all()
if type_filter == "wireless":
devices = [d for d in all_devices if d.device_type == "uap"]
elif type_filter == "wired":
devices = [d for d in all_devices if d.device_type != "uap"]
elif type_filter == "offline":
devices = [d for d in all_devices if d.state != 1]
else:
devices = list(all_devices)
return await render_template(
"unifi/widget_devices.html",
devices=devices,
type_filter=type_filter,
widget_id=widget_id,
)
@@ -1,36 +0,0 @@
{# unifi/widget_devices.html — dashboard widget: infrastructure devices #}
{% if not devices %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
{% if type_filter != "all" %}No {{ type_filter }} devices found.{% else %}No devices found.{% endif %}
</div>
{% else %}
{% set online = devices | selectattr("state", "equalto", 1) | list %}
{% set offline = devices | rejectattr("state", "equalto", 1) | list %}
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
<div>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ online | length }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
</div>
{% if offline %}
<div>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ offline | length }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
</div>
{% endif %}
</div>
<div style="display:grid;gap:3px;">
{% for d in devices %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
<span class="dot {% if d.state == 1 %}dot-up{% else %}dot-down{% endif %}"></span>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ d.name or d.mac }}
</span>
<span style="font-size:0.72rem;color:var(--text-dim);flex-shrink:0;">{{ d.model or d.device_type or "" }}</span>
</div>
{% endfor %}
</div>
{% endif %}
+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 %}
+108
View File
@@ -0,0 +1,108 @@
"""The dashboard starts from a curated layout, the duplicate widgets are gone,
and the Overview says what each number counts (#5213)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from quart import render_template, session
from steward.core.status import StatusEntry
from steward.core.widgets import RECOMMENDED, RETIRED, WIDGET_REGISTRY, recommended_layout
from steward.status.routes import overview_alerts
from tests.test_style_tokens import hex_colours
ALL_ON = {p: {"enabled": True} for p in ("docker", "host_agent", "traefik", "unifi", "snmp")}
def _overlap(a, b):
return (a["x"] < b["x"] + b["w"] and b["x"] < a["x"] + a["w"]
and a["y"] < b["y"] + b["h"] and b["y"] < a["y"] + a["h"])
def test_retired_widgets_point_at_the_one_that_replaced_them():
for old, new in RETIRED.items():
assert old not in WIDGET_REGISTRY and new in WIDGET_REGISTRY
assert all(key in WIDGET_REGISTRY for key, *_ in RECOMMENDED)
def test_the_recommended_layout_fills_rows_without_overlap():
items = recommended_layout(ALL_ON)
assert [i["key"] for i in items] == [key for key, *_ in RECOMMENDED]
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 12, "h": 3, "config": {}}
assert (items[1]["x"], items[1]["y"], items[2]["x"], items[2]["y"]) == (0, 3, 6, 3)
for i, a in enumerate(items):
assert a["x"] + a["w"] <= 12
assert not any(_overlap(a, b) for b in items[i + 1:]), a
def test_the_recommended_layout_skips_what_is_not_enabled():
keys = [i["key"] for i in recommended_layout({})]
assert keys == ["status_overview", "hosts_overview", "monitors", "uptime_summary"]
# uptime no longer fits beside the monitors, so it starts the next row
items = recommended_layout({})
assert (items[3]["x"], items[3]["y"]) == (0, 10)
def test_overview_alerts_put_the_breaching_first():
from steward.models.alerts import AlertStateEnum
ack = SimpleNamespace(state=AlertStateEnum.acknowledged, last_transition_at=None, no_data_since=None)
silent = SimpleNamespace(state=AlertStateEnum.inactive, last_transition_at=None,
no_data_since=datetime(2026, 10, 7, tzinfo=timezone.utc))
firing = SimpleNamespace(state=AlertStateEnum.firing, last_transition_at=datetime(2026, 10, 6, tzinfo=timezone.utc),
no_data_since=None)
rows = overview_alerts([(ack, "r-ack"), (silent, "r-silent"), (firing, "r-firing")])
assert [(r["rule"], r["label"]) for r in rows] == [
("r-firing", "firing"), ("r-silent", "no_data"), ("r-ack", "acknowledged")]
assert rows[1]["since"] == silent.no_data_since # silent since the series stopped
assert rows[0]["since"] == firing.last_transition_at
async def test_the_overview_labels_its_counts_and_lists_what_is_wrong(app):
now = datetime.now(timezone.utc)
rule = SimpleNamespace(id="r1", name="nas disk full")
alerts = [{"rule": rule, "label": "firing", "since": now - timedelta(minutes=12)}]
entries = [
StatusEntry(kind="http", key="1", name="web", status="up", detail_url="/monitors/1"),
StatusEntry(kind="tcp", key="2", name="ssh-nas", status="down", detail_url="/monitors/2",
since=now - timedelta(hours=2)),
]
async with app.test_request_context("/status/widget"):
session["user_id"] = "u1"
page = await render_template("status/widget.html", entries=entries, up=1, down=1, pending=0,
total_hosts=3, alerts=alerts, widget_id="1")
assert ">Monitors<" in page and ">Alerts<" in page and ">firing<" in page and ">hosts<" in page
assert '<span class="badge badge-crit">Firing</span>' in page
assert 'href="/alerts/history?rule=r1"' in page and "12m ago" in page
assert '<span class="badge badge-crit">Down</span>' in page and "2h ago" in page
assert page.index("nas disk full") < page.index("ssh-nas") # alerts, then monitors
assert ">pending<" not in page # a zero pending count is not shown
assert not hex_colours(page), hex_colours(page)
async def test_a_quiet_overview_says_so(app):
async with app.test_request_context("/status/widget"):
page = await render_template("status/widget.html", entries=[], up=0, down=0, pending=0,
total_hosts=1, alerts=[], widget_id="1")
assert "No monitors configured, and no alert is firing." in page
assert ">host<" in page and 'href="/alerts/"' not in page # the share view has no links
async def test_the_hosts_widget_shows_load(app):
host = SimpleNamespace(id="h1", name="web-1")
agent = {"web-1": {"cpu_pct": 12.0, "mem_used_pct": 40.0, "load_1m": 0.734, "fresh": True}}
async with app.test_request_context("/hosts/overview/widget"):
page = await render_template("hosts/overview_widget.html", hosts=[host], host_status={},
uptime={}, agent=agent, cpu_sparks={})
assert ">load</span> 0.73" in page
async def test_a_widget_polls_at_its_own_pace(app):
defn = {**WIDGET_REGISTRY["uptime_summary"]}
item = SimpleNamespace(db=SimpleNamespace(id=7, grid_x=0, grid_y=0, grid_w=6, grid_h=5),
title="Uptime", defn=defn, hx_url="/uptime/widget")
async with app.test_request_context("/"):
page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30)
assert "every 300s" in page
defn.pop("poll_seconds")
async with app.test_request_context("/"):
page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30)
assert "every 30s" in page
+69
View File
@@ -0,0 +1,69 @@
"""Integration: "Reset to the recommended layout" replaces a dashboard's
widgets with widgets.RECOMMENDED, and only for someone who may edit it
(#5213), against real Postgres."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role):
from steward.models.users import User, UserRole
uid, tag = str(uuid.uuid4()), uuid.uuid4().hex[:8]
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"reset-{tag}", email=f"reset-{tag}@test",
password_hash="x", role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=f"reset-{tag}",
_csrf="test-token", login_at=time.time())
return client, uid
CSRF = {"X-CSRF-Token": "test-token"}
def test_reset_puts_back_the_recommended_layout_for_its_owner_only(app):
from sqlalchemy import select
from steward.core.widgets import recommended_layout
from steward.models.dashboard import Dashboard, DashboardWidget
async def _go():
owner, owner_id = await _client(app, "operator")
other, _ = await _client(app, "operator")
async with app.db_sessionmaker() as s:
async with s.begin():
dash = Dashboard(name="mine", owner_id=owner_id)
s.add(dash)
await s.flush()
s.add(DashboardWidget(dashboard_id=dash.id, widget_key="monitors",
grid_x=3, grid_y=9, grid_w=2, grid_h=2))
dash_id = dash.id
assert (await other.post(f"/d/{dash_id}/edit/reset", headers=CSRF)).status_code == 403
resp = await owner.post(f"/d/{dash_id}/edit/reset", headers=CSRF)
assert resp.status_code == 302 and resp.headers["Location"].endswith(f"/d/{dash_id}")
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(DashboardWidget).where(DashboardWidget.dashboard_id == dash_id)
.order_by(DashboardWidget.grid_y, DashboardWidget.grid_x))).scalars().all()
want = recommended_layout(app.config.get("PLUGINS", {}))
assert [(w.widget_key, w.grid_x, w.grid_y, w.grid_w, w.grid_h) for w in rows] == \
[(i["key"], i["x"], i["y"], i["w"], i["h"]) for i in want]
asyncio.run(_go())
+1 -1
View File
@@ -52,7 +52,7 @@ def test_every_route_says_who_may_reach_it():
def test_the_scan_sees_routes():
names = {n.name for _, text in _sources() for n, _ in route_views(text)}
assert {"login", "health", "ingest", "widget_table", "public_share"} <= names
assert {"login", "health", "ingest", "widget_history", "public_share"} <= names
@pytest.mark.asyncio