feat: drag-and-drop dashboard editing, alert dynamic fields

- Replace up/down arrow reorder buttons on dashboard edit page with
  SortableJS drag handles; add POST /d/<id>/edit/reorder endpoint
  accepting ordered widget ID array
- Add {% block extra_scripts %} hook to base.html for page-specific JS
- Add dynamic alert rule field loading via HTMX partial (_rule_fields)
  so metric/threshold fields update when source is changed
- Add docs/plugins/index.yaml.example showing catalog index format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:16:04 -04:00
parent 93d5ed8fb0
commit edc9b752d2
8 changed files with 423 additions and 58 deletions
+55 -24
View File
@@ -1,7 +1,9 @@
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 fabledscryer.auth.middleware import require_role, current_user_id
@@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
continue
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
continue
out.append({"db": w, "defn": defn})
# 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
@@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple:
widgets = await _get_widgets(db, dashboard_id)
plugins_cfg = current_app.config.get("PLUGINS", {})
available = get_available_widgets(plugins_cfg)
placed_keys = {w.widget_key for w in widgets}
addable = [w for w in available if w["key"] not in placed_keys]
# Multiple instances of the same widget type are allowed — show all available
resolved = [
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key]}
{"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, addable
return dash, resolved, available
async def _panels_response(dashboard_id: int):
@@ -367,20 +383,36 @@ async def edit(dash_id: int):
async def add_widget(dash_id: int, widget_key: str):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
if widget_key not in WIDGET_REGISTRY:
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)
if not any(w.widget_key == widget_key for w in widgets):
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,
))
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)
@@ -405,28 +437,27 @@ async def remove_widget(dash_id: int, widget_id: int):
return await _panels_response(dash_id)
@dashboard_bp.post("/d/<int:dash_id>/edit/move/<int:widget_id>/<direction>")
@dashboard_bp.post("/d/<int:dash_id>/edit/reorder")
@require_role(UserRole.viewer)
async def move_widget(dash_id: int, widget_id: int, direction: str):
async def reorder_widgets(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
if direction not in ("up", "down"):
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)
idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None)
if idx is not None:
swap_idx = (idx - 1) if direction == "up" else (idx + 1)
if 0 <= swap_idx < len(widgets):
widgets[idx].position, widgets[swap_idx].position = (
widgets[swap_idx].position, widgets[idx].position
)
return await _panels_response(dash_id)
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 ──────────────────────────────────────────────────────────────