feat(dashboard): edit in place over the live dashboard with a bottom widget drawer
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m16s
CI / publish (push) Successful in 58s

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:
2026-06-17 15:42:10 -04:00
parent 988c13d51f
commit 88dca32d3c
8 changed files with 257 additions and 270 deletions
+32 -49
View File
@@ -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")