feat(dashboard): edit in place over the live dashboard with a bottom widget drawer
Milestone 72 phase D — the dashboard view IS the edit surface (operator ask): - /d/<id> renders the grid via Gridstack in STATIC mode — positioned exactly like before, live HTMX widget bodies keep polling. An "Edit" button flips the same grid interactive (grid.setStatic(false)) in place, so you drag/resize over real data. "Done" flips it back. Layout autosaves on change. - The widget picker is now a bottom drawer (position:fixed overlay) revealed by body.dash-editing — so the dashboard width is identical entering/leaving edit. - Add: POST returns a single grid item; JS inserts it + grid.makeWidget + htmx.process so it loads live data. Remove: POST 204 + grid.removeWidget. Per-panel drag handle + remove ✕ are in the DOM for editors, shown only while editing. - Gridstack loads for everyone (viewers get the static grid); edit wiring is gated on can_edit. Mobile collapses to one column. - Removed the separate /d/<id>/edit route + edit.html + _edit_panels.html (rule 22). Dashboard-list Edit and new-dashboard create now deep-link /d/<id>?edit=1 which opens edit mode on load. Browser-only behaviour — CI can't exercise it; needs an operator visual check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+32
-49
@@ -169,33 +169,6 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
|
||||
|
||||
# ── 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
|
||||
]
|
||||
# Hosts feed the "host" param dropdown (e.g. the history-graph widget).
|
||||
hosts = list((await db.execute(select(Host).order_by(Host.name))).scalars())
|
||||
return dash, resolved, available, hosts
|
||||
|
||||
|
||||
async def _panels_response(dashboard_id: int):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
dash, resolved, addable, hosts = await _get_panels_data(db, dashboard_id)
|
||||
return await render_template(
|
||||
"dashboard/_edit_panels.html",
|
||||
dashboard=dash, widgets=resolved, addable=addable, hosts=hosts,
|
||||
)
|
||||
|
||||
|
||||
# ── Root redirect ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/")
|
||||
@@ -229,10 +202,18 @@ async def view(dash_id: int):
|
||||
stats = await _get_summary_stats(db)
|
||||
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)
|
||||
can_edit = _can_edit(dash, user_id, user_role)
|
||||
|
||||
return await render_template(
|
||||
"dashboard/index.html",
|
||||
@@ -241,6 +222,8 @@ async def view(dash_id: int):
|
||||
widgets=resolved,
|
||||
poll_interval=poll_interval,
|
||||
can_edit=can_edit,
|
||||
addable=addable,
|
||||
hosts=hosts,
|
||||
**stats,
|
||||
)
|
||||
|
||||
@@ -282,7 +265,8 @@ async def create_dashboard():
|
||||
db.add(dash)
|
||||
await db.flush()
|
||||
dash_id = dash.id
|
||||
return redirect(f"/d/{dash_id}/edit")
|
||||
# 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")
|
||||
@@ -363,26 +347,12 @@ async def delete_dashboard(dash_id: int):
|
||||
return redirect("/dashboards/")
|
||||
|
||||
|
||||
# ── Dashboard edit ────────────────────────────────────────────────────────────
|
||||
|
||||
@dashboard_bp.get("/d/<int:dash_id>/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, hosts = 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, hosts=hosts,
|
||||
)
|
||||
|
||||
# ── 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)
|
||||
@@ -412,12 +382,24 @@ async def add_widget(dash_id: int, widget_key: str):
|
||||
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)
|
||||
db.add(DashboardWidget(
|
||||
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,
|
||||
))
|
||||
return await _panels_response(dash_id)
|
||||
)
|
||||
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>")
|
||||
@@ -437,7 +419,8 @@ async def remove_widget(dash_id: int, widget_id: int):
|
||||
widget = result.scalar_one_or_none()
|
||||
if widget and widget.dashboard_id == dash_id:
|
||||
await db.delete(widget)
|
||||
return await _panels_response(dash_id)
|
||||
# Client removes the DOM/grid item itself; nothing to render back.
|
||||
return ("", 204)
|
||||
|
||||
|
||||
@dashboard_bp.post("/d/<int:dash_id>/edit/layout")
|
||||
|
||||
Reference in New Issue
Block a user