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
+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):