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:
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user