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.monitors import PingResult, DnsResult 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]: result = await db.execute( select(DashboardWidget) .where(DashboardWidget.dashboard_id == dashboard_id) .order_by(DashboardWidget.position) ) return list(result.scalars()) async def _normalise_positions(db, dashboard_id: int) -> None: for i, w in enumerate(await _get_widgets(db, dashboard_id)): w.position = i 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", {}) for pos, w in enumerate(get_available_widgets(plugins_cfg)): db.add(DashboardWidget( dashboard_id=dash.id, widget_key=w["key"], position=pos, )) return dash async def _get_summary_stats(db) -> dict: latest_ping_subq = ( select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at")) .group_by(PingResult.host_id).subquery() ) pr = await db.execute( select(PingResult).join( latest_ping_subq, (PingResult.host_id == latest_ping_subq.c.host_id) & (PingResult.probed_at == latest_ping_subq.c.max_at), ) ) pings = list(pr.scalars()) ping_up = sum(1 for p in pings if p.status.value == "up") ping_down = sum(1 for p in pings if p.status.value == "down") latest_dns_subq = ( select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at")) .group_by(DnsResult.host_id).subquery() ) dr = await db.execute( select(DnsResult).join( latest_dns_subq, (DnsResult.host_id == latest_dns_subq.c.host_id) & (DnsResult.resolved_at == latest_dns_subq.c.max_at), ) ) dns = list(dr.scalars()) dns_ok = sum(1 for d in dns if d.status.value == "resolved") dns_fail = sum(1 for d in dns if d.status.value != "resolved") total = (await db.execute(select(func.count()).select_from(Host))).scalar() return dict(ping_up=ping_up, ping_down=ping_down, dns_ok=dns_ok, dns_fail=dns_fail, total_hosts=total) 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 ──────────────────────────────────────────────────────── async def _get_panels_data(db, dashboard_id: int) -> tuple: result = await db.execute(select(Dashboard).where(Dashboard.id == dashboard_id)) dash = result.scalar_one_or_none() if dash is None: return None, [], [] widgets = await _get_widgets(db, dashboard_id) plugins_cfg = current_app.config.get("PLUGINS", {}) available = get_available_widgets(plugins_cfg) # Multiple instances of the same widget type are allowed — show all available resolved = [ {"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]} for w in widgets if w.widget_key in WIDGET_REGISTRY ] return dash, resolved, available async def _panels_response(dashboard_id: int): async with current_app.db_sessionmaker() as db: dash, resolved, addable = await _get_panels_data(db, dashboard_id) return await render_template( "dashboard/_edit_panels.html", dashboard=dash, widgets=resolved, addable=addable, ) # ── 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/") @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) stats = await _get_summary_stats(db) widgets = await _get_widgets(db, dash_id) accessible = await _accessible_dashboards(db, user_id, user_role) resolved = _resolve_widgets(widgets) poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) can_edit = _can_edit(dash, user_id, user_role) return await render_template( "dashboard/index.html", dashboard=dash, all_dashboards=accessible, widgets=resolved, poll_interval=poll_interval, can_edit=can_edit, **stats, ) # ── 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 return redirect(f"/d/{dash_id}/edit") @dashboard_bp.post("/dashboards//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//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//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//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 ──────────────────────────────────────────────────────────── @dashboard_bp.get("/d//edit") @require_role(UserRole.viewer) async def edit(dash_id: int): user_id = current_user_id() user_role = session.get("user_role", "viewer") async with current_app.db_sessionmaker() as db: dash, resolved, addable = await _get_panels_data(db, dash_id) if dash is None or not _can_edit(dash, user_id, user_role): abort(403) return await render_template( "dashboard/edit.html", dashboard=dash, widgets=resolved, addable=addable, ) @dashboard_bp.post("/d//edit/add/") @require_role(UserRole.viewer) async def add_widget(dash_id: int, widget_key: str): 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) next_pos = max((w.position for w in widgets), default=-1) + 1 db.add(DashboardWidget( dashboard_id=dash_id, widget_key=widget_key, position=next_pos, title=title, config_json=config_json, )) return await _panels_response(dash_id) @dashboard_bp.post("/d//edit/remove/") @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) await _normalise_positions(db, dash_id) return await _panels_response(dash_id) @dashboard_bp.post("/d//edit/reorder") @require_role(UserRole.viewer) async def reorder_widgets(dash_id: int): 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("order"), list): abort(400) order: list[int] = data["order"] 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) widget_map = {w.id: w for w in widgets} for pos, wid in enumerate(order): if wid in widget_map: widget_map[wid].position = pos return ("", 204) # ── Share tokens ────────────────────────────────────────────────────────────── @dashboard_bp.get("/d//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//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//share/revoke/") @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/") 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, )