Files
bvandeusen ae03f09234
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 51s
feat(dashboard): merge top summary strip + Status widget into one Overview
Operator chose a single canonical summary. Repurpose the status_overview
widget into 'Overview' (host count + monitor up/down/pending + Alerts link;
key kept so existing dashboards upgrade in place) and remove the redundant
fixed top strip from the dashboard view. Drops _get_summary_stats and its
now-unused PingResult/DnsResult imports (rule 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:40:26 -04:00

548 lines
22 KiB
Python

from __future__ import annotations
import hashlib
import json
import secrets
from datetime import datetime, timezone, timedelta
from urllib.parse import urlencode
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
from sqlalchemy import select, func, update
from steward.auth.middleware import require_role, current_user_id
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
dashboard_bp = Blueprint("dashboard", __name__)
# ── Access helpers ────────────────────────────────────────────────────────────
def _can_view(dash: Dashboard, user_id: str, user_role: str) -> bool:
if user_role == UserRole.admin:
return True
if dash.owner_id is None: # system dashboard
return True
if dash.owner_id == user_id: # own dashboard
return True
return dash.is_shared # shared by another user
def _can_edit(dash: Dashboard, user_id: str, user_role: str) -> bool:
if user_role == UserRole.admin:
return True
return dash.owner_id == user_id
# ── DB helpers ────────────────────────────────────────────────────────────────
async def _get_widgets(db, dashboard_id: int) -> list[DashboardWidget]:
# Reading order = grid order (top-to-bottom, then left-to-right). This drives
# DOM order, which is what the responsive single-column fallback collapses to.
result = await db.execute(
select(DashboardWidget)
.where(DashboardWidget.dashboard_id == dashboard_id)
.order_by(DashboardWidget.grid_y, DashboardWidget.grid_x, DashboardWidget.id)
)
return list(result.scalars())
async def _accessible_dashboards(db, user_id: str, user_role: str) -> list[Dashboard]:
"""All dashboards visible to this user, ordered by id."""
result = await db.execute(select(Dashboard).order_by(Dashboard.id))
all_dashes = list(result.scalars())
return [d for d in all_dashes if _can_view(d, user_id, user_role)]
async def _resolve_default(db, user_id: str, user_role: str) -> Dashboard | None:
"""
1. User's own dashboard with is_default=True
2. System dashboard (owner_id=None) with is_default=True
3. First accessible dashboard
"""
result = await db.execute(
select(Dashboard)
.where(Dashboard.owner_id == user_id, Dashboard.is_default.is_(True))
.limit(1)
)
dash = result.scalar_one_or_none()
if dash:
return dash
result = await db.execute(
select(Dashboard)
.where(Dashboard.owner_id.is_(None), Dashboard.is_default.is_(True))
.limit(1)
)
dash = result.scalar_one_or_none()
if dash:
return dash
accessible = await _accessible_dashboards(db, user_id, user_role)
return accessible[0] if accessible else None
async def _get_or_create_system_default(db) -> Dashboard:
"""Ensure a system (owner_id=None) default dashboard exists."""
result = await db.execute(
select(Dashboard).where(Dashboard.owner_id.is_(None)).limit(1)
)
dash = result.scalar_one_or_none()
if dash is None:
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,
))
return dash
def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
plugins_cfg = current_app.config.get("PLUGINS", {})
out = []
for w in widgets:
defn = WIDGET_REGISTRY.get(w.widget_key)
if defn is None:
continue
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
continue
# Build stored config (falls back to empty; defaults live in the registry)
config: dict = {}
if w.config_json:
try:
config = json.loads(w.config_json)
except (ValueError, TypeError):
pass
# Always append wid so widget templates can create unique canvas IDs
url_params = {**config, "wid": w.id}
hx_url = defn["hx_url"] + "?" + urlencode(url_params)
out.append({
"db": w,
"defn": defn,
"hx_url": hx_url,
"title": w.title or defn["label"],
})
return out
# ── Edit panel helpers ────────────────────────────────────────────────────────
# ── Root redirect ─────────────────────────────────────────────────────────────
@dashboard_bp.get("/")
@require_role(UserRole.viewer)
async def index():
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _get_or_create_system_default(db)
dash = await _resolve_default(db, user_id, user_role)
if dash is None:
abort(404)
return redirect(f"/d/{dash.id}")
# ── Dashboard view ────────────────────────────────────────────────────────────
@dashboard_bp.get("/d/<int:dash_id>")
@require_role(UserRole.viewer)
async def view(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_view(dash, user_id, user_role):
abort(404)
widgets = await _get_widgets(db, dash_id)
accessible = await _accessible_dashboards(db, user_id, user_role)
can_edit = _can_edit(dash, user_id, user_role)
# Editors get the bottom-drawer picker (available widgets + host list);
# viewers/shared-readers don't, so skip the extra queries for them.
if can_edit:
plugins_cfg = current_app.config.get("PLUGINS", {})
addable = get_available_widgets(plugins_cfg)
hosts = list((await db.execute(select(Host).order_by(Host.name))).scalars())
else:
addable, hosts = [], []
resolved = _resolve_widgets(widgets)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"dashboard/index.html",
dashboard=dash,
all_dashboards=accessible,
widgets=resolved,
poll_interval=poll_interval,
can_edit=can_edit,
addable=addable,
hosts=hosts,
)
# ── Dashboard management ──────────────────────────────────────────────────────
@dashboard_bp.get("/dashboards/")
@require_role(UserRole.viewer)
async def list_dashboards():
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _get_or_create_system_default(db)
accessible = await _accessible_dashboards(db, user_id, user_role)
my_dashes = [d for d in accessible if d.owner_id == user_id]
shared_dashes = [d for d in accessible if d.owner_id != user_id]
return await render_template(
"dashboard/list.html",
my_dashboards=my_dashes,
shared_dashboards=shared_dashes,
user_id=user_id,
user_role=user_role,
)
@dashboard_bp.post("/dashboards/create")
@require_role(UserRole.viewer)
async def create_dashboard():
user_id = current_user_id()
form = await request.form
name = (form.get("name") or "New Dashboard").strip()[:128]
async with current_app.db_sessionmaker() as db:
async with db.begin():
dash = Dashboard(name=name, owner_id=user_id, is_default=False, is_shared=False)
db.add(dash)
await db.flush()
dash_id = dash.id
# Open the new (empty) dashboard straight into edit mode to add widgets.
return redirect(f"/d/{dash_id}?edit=1")
@dashboard_bp.post("/dashboards/<int:dash_id>/rename")
@require_role(UserRole.viewer)
async def rename_dashboard(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
form = await request.form
name = (form.get("name") or "").strip()[:128]
if name:
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash and _can_edit(dash, user_id, user_role):
dash.name = name
return redirect("/dashboards/")
@dashboard_bp.post("/dashboards/<int:dash_id>/toggle-share")
@require_role(UserRole.viewer)
async def toggle_share(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash and _can_edit(dash, user_id, user_role):
dash.is_shared = not dash.is_shared
return redirect("/dashboards/")
@dashboard_bp.post("/dashboards/<int:dash_id>/set-default")
@require_role(UserRole.viewer)
async def set_default_dashboard(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_view(dash, user_id, user_role):
abort(404)
# Clear is_default on all dashboards this user owns
await db.execute(
update(Dashboard)
.where(Dashboard.owner_id == user_id)
.values(is_default=False)
)
# If setting a system/shared dashboard as default, we instead create
# a personal preference record by marking the system one (safe since
# system dashboards are shared — the flag just signals this user's preference)
dash.is_default = True
return redirect("/dashboards/")
@dashboard_bp.post("/dashboards/<int:dash_id>/delete")
@require_role(UserRole.viewer)
async def delete_dashboard(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
# Count remaining owned by this user (don't delete their last one if any)
owned_count = (await db.execute(
select(func.count()).select_from(Dashboard)
.where(Dashboard.owner_id == user_id)
)).scalar()
if dash.owner_id == user_id and owned_count <= 1:
pass # refuse to delete user's last owned dashboard
else:
await db.delete(dash)
return redirect("/dashboards/")
# ── Dashboard edit (in-place over the live dashboard) ─────────────────────────
@dashboard_bp.post("/d/<int:dash_id>/edit/add/<widget_key>")
@require_role(UserRole.viewer)
async def add_widget(dash_id: int, widget_key: str):
"""Add a widget and return its single grid-item HTML for in-place insertion."""
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
defn = WIDGET_REGISTRY.get(widget_key)
if defn is None:
abort(400)
form = await request.form
title = form.get("title", "").strip() or None
# Parse configured params
config: dict = {}
for p in defn.get("params", []):
val = form.get(f"param_{p['key']}")
if val is not None:
if isinstance(p["default"], int):
try:
config[p["key"]] = int(val)
except ValueError:
config[p["key"]] = p["default"]
else:
config[p["key"]] = val
config_json = json.dumps(config) if config else None
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
widgets = await _get_widgets(db, dash_id)
# Append below everything at full-left; the user then drags/resizes.
next_y = max((w.grid_y + w.grid_h for w in widgets), default=0)
new = DashboardWidget(
dashboard_id=dash_id, widget_key=widget_key,
grid_x=0, grid_y=next_y, grid_w=4, grid_h=4,
title=title, config_json=config_json,
)
db.add(new)
await db.flush()
new_id = new.id
new = (await db.execute(
select(DashboardWidget).where(DashboardWidget.id == new_id))).scalar_one()
resolved = _resolve_widgets([new])
if not resolved:
return ("", 204)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"dashboard/_grid_item.html",
item=resolved[0], poll_interval=poll_interval, can_edit=True,
)
@dashboard_bp.post("/d/<int:dash_id>/edit/remove/<int:widget_id>")
@require_role(UserRole.viewer)
async def remove_widget(dash_id: int, widget_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
result = await db.execute(
select(DashboardWidget).where(DashboardWidget.id == widget_id)
)
widget = result.scalar_one_or_none()
if widget and widget.dashboard_id == dash_id:
await db.delete(widget)
# Client removes the DOM/grid item itself; nothing to render back.
return ("", 204)
@dashboard_bp.post("/d/<int:dash_id>/edit/layout")
@require_role(UserRole.viewer)
async def save_layout(dash_id: int):
"""Persist the drag-resize grid (Gridstack) — one {id,x,y,w,h} per widget."""
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
data = await request.get_json(silent=True)
if not data or not isinstance(data.get("items"), list):
abort(400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
widget_map = {w.id: w for w in await _get_widgets(db, dash_id)}
for it in data["items"]:
if not isinstance(it, dict):
continue
w = widget_map.get(it.get("id"))
if w is None:
continue
try:
# Clamp to the 12-col grid; widths can't exceed it.
w.grid_x = max(0, min(11, int(it["x"])))
w.grid_w = max(1, min(12, int(it["w"])))
w.grid_y = max(0, int(it["y"]))
w.grid_h = max(1, int(it["h"]))
except (KeyError, TypeError, ValueError):
continue
return ("", 204)
# ── Share tokens ──────────────────────────────────────────────────────────────
@dashboard_bp.get("/d/<int:dash_id>/share")
@require_role(UserRole.viewer)
async def manage_share_tokens(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
result = await db.execute(
select(DashboardShareToken)
.where(DashboardShareToken.dashboard_id == dash_id)
.order_by(DashboardShareToken.created_at.desc())
)
tokens = list(result.scalars())
now = datetime.now(timezone.utc)
return await render_template(
"dashboard/share.html",
dashboard=dash, tokens=tokens, now=now,
)
@dashboard_bp.post("/d/<int:dash_id>/share/create")
@require_role(UserRole.viewer)
async def create_share_token(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
form = await request.form
label = (form.get("label") or "").strip()[:128]
expires_in = form.get("expires_in", "")
expires_at = None
if expires_in.isdigit() and int(expires_in) > 0:
expires_at = datetime.now(timezone.utc) + timedelta(days=int(expires_in))
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(DashboardShareToken(
dashboard_id=dash_id,
token_hash=token_hash,
label=label,
expires_at=expires_at,
created_by=user_id,
created_at=datetime.now(timezone.utc),
))
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
share_url = f"{public_base_url(request)}/share/{raw_token}"
return await render_template(
"dashboard/share_created.html",
dashboard=dash,
raw_token=raw_token,
label=label,
expires_at=expires_at,
share_url=share_url,
)
@dashboard_bp.post("/d/<int:dash_id>/share/revoke/<int:token_id>")
@require_role(UserRole.viewer)
async def revoke_share_token(dash_id: int, token_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
result = await db.execute(
select(DashboardShareToken).where(DashboardShareToken.id == token_id)
)
token = result.scalar_one_or_none()
if token and token.dashboard_id == dash_id:
await db.delete(token)
return redirect(f"/d/{dash_id}/share")
# ── Public share view (no login required) ────────────────────────────────────
@dashboard_bp.get("/share/<raw_token>")
async def public_share(raw_token: str):
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(DashboardShareToken)
.where(DashboardShareToken.token_hash == token_hash)
)
share = result.scalar_one_or_none()
if share is None:
abort(404)
if share.expires_at is not None and share.expires_at <= now:
abort(410)
result = await db.execute(
select(Dashboard).where(Dashboard.id == share.dashboard_id)
)
dash = result.scalar_one_or_none()
if dash is None:
abort(404)
widgets = await _get_widgets(db, dash.id)
resolved = _resolve_widgets(widgets)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"dashboard/share_view.html",
dashboard=dash,
widgets=resolved,
poll_interval=poll_interval,
raw_token=raw_token,
)