From 88dca32d3c81f14ffaa1211239e16e7bff066b96 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 17 Jun 2026 15:42:10 -0400 Subject: [PATCH] feat(dashboard): edit in place over the live dashboard with a bottom widget drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 72 phase D — the dashboard view IS the edit surface (operator ask): - /d/ 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//edit route + edit.html + _edit_panels.html (rule 22). Dashboard-list Edit and new-dashboard create now deep-link /d/?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) --- steward/dashboard/routes.py | 81 +++++------ steward/templates/base.html | 32 +++-- steward/templates/dashboard/_edit_panels.html | 127 ------------------ steward/templates/dashboard/_grid_item.html | 25 ++++ .../templates/dashboard/_widget_drawer.html | 78 +++++++++++ steward/templates/dashboard/edit.html | 58 -------- steward/templates/dashboard/index.html | 122 +++++++++++++---- steward/templates/dashboard/list.html | 4 +- 8 files changed, 257 insertions(+), 270 deletions(-) delete mode 100644 steward/templates/dashboard/_edit_panels.html create mode 100644 steward/templates/dashboard/_grid_item.html create mode 100644 steward/templates/dashboard/_widget_drawer.html delete mode 100644 steward/templates/dashboard/edit.html diff --git a/steward/dashboard/routes.py b/steward/dashboard/routes.py index 57a82d4..ddf32a3 100644 --- a/steward/dashboard/routes.py +++ b/steward/dashboard/routes.py @@ -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//rename") @@ -363,26 +347,12 @@ async def delete_dashboard(dash_id: int): 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, 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//edit/add/") @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//edit/remove/") @@ -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//edit/layout") diff --git a/steward/templates/base.html b/steward/templates/base.html index 32cfb37..1652153 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -165,18 +165,28 @@ textarea { resize: vertical; } .ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; } .ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; } a.ping-name:hover { text-decoration:underline; } -/* Dashboard widget grid — Grafana-style 12-col layout; cells sized by drag-resize - (Gridstack) in the edit view and rendered statically here from each widget's - x/y/w/h. Cell height is fixed (h * cell unit) with the body scrolling, so the - layout the operator arranged is exactly what they see. */ -.dash-grid { display:grid; grid-template-columns:repeat(12,1fr); grid-auto-rows:70px; gap:1rem; } -.dash-grid > .dash-cell { margin-bottom:0; min-width:0; overflow:hidden; display:flex; flex-direction:column; } -.dash-cell .dash-cell-body { flex:1; min-height:0; overflow:auto; } -@media (max-width:820px) { - .dash-grid { grid-template-columns:1fr; grid-auto-rows:auto; } - .dash-grid > .dash-cell { grid-column:1 / -1 !important; grid-row:auto !important; } - .dash-cell .dash-cell-body { overflow:visible; } +/* Dashboard widget grid — Gridstack-rendered. Static (positioned but inert) in + view mode; the dashboard itself becomes the edit surface when Edit is toggled, + so drag-resize happens over live widget data. The bottom drawer is a fixed + overlay, so the dashboard width is identical entering/leaving edit mode. */ +.grid-stack-item-content { + background:var(--bg-card); border:1px solid var(--border-mid); border-radius:8px; + padding:0.8rem 1rem; display:flex; flex-direction:column; overflow:hidden; } +.grid-stack-item-content .dash-cell-body { flex:1; min-height:0; overflow:auto; } +.panel-chrome { display:none; } +.panel-drag { cursor:grab; color:var(--text-dim); user-select:none; } +.panel-drag:active { cursor:grabbing; } +body.dash-editing .panel-chrome { display:inline-flex; align-items:center; } +body.dash-editing .grid-stack-item-content { outline:1px dashed var(--border-mid); outline-offset:-1px; } +body.dash-editing main { padding-bottom:46vh; } +.widget-drawer { + position:fixed; left:0; right:0; bottom:0; transform:translateY(100%); + transition:transform .25s ease; max-height:44vh; overflow-y:auto; + background:var(--bg-card); border-top:1px solid var(--border-mid); + box-shadow:0 -10px 30px rgba(0,0,0,0.45); z-index:60; padding:1rem 1.5rem 1.5rem; +} +body.dash-editing .widget-drawer { transform:translateY(0); } .ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; } .pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; } diff --git a/steward/templates/dashboard/_edit_panels.html b/steward/templates/dashboard/_edit_panels.html deleted file mode 100644 index 2a410e6..0000000 --- a/steward/templates/dashboard/_edit_panels.html +++ /dev/null @@ -1,127 +0,0 @@ -{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #} -
- - {# ── Current layout (drag to move, drag the corner/edge to resize) ──────── #} -
-
-
Current Layout
- {% if widgets %} - Drag to move · drag a corner to resize · saved automatically - {% endif %} -
- {% if widgets %} -
- {% for item in widgets %} -
-
-
- - {{ item.title }} - -
-
{{ item.defn.description }}
-
-
- {% endfor %} -
- {% else %} -
- No widgets yet. Add one from the right to begin. -
- {% endif %} -
- - {# ── Available widgets ────────────────────────────────────────────────── #} -
-
Available Widgets
- {% if addable %} - {% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %} - {% for _gkey, _glabel in _groups %} - {% set _gw = addable | selectattr("group", "equalto", _gkey) | list %} - {% if _gw %} -
{{ _glabel }}
-
- {% for w in _gw %} -
-
- -
-
{{ w.label }}
-
{{ w.description }}
-
- Add ▾ -
- -
-
- -
- - -
- - {% for p in w.params %} -
- - {% if p.type == "select" %} - - {% elif p.type == "host" %} - - {% endif %} -
- {% endfor %} - - -
-
-
-
- {% endfor %} -
- {% endif %} - {% endfor %} - {% else %} -
- No plugin widgets available. Enable plugins in Settings → Plugins. -
- {% endif %} -
- -
diff --git a/steward/templates/dashboard/_grid_item.html b/steward/templates/dashboard/_grid_item.html new file mode 100644 index 0000000..e5562b3 --- /dev/null +++ b/steward/templates/dashboard/_grid_item.html @@ -0,0 +1,25 @@ +{# One dashboard widget as a Gridstack item — shared by index.html (initial render) + and the add-widget endpoint (single-item insert). Edit chrome (drag handle + + remove) is in the DOM only for editors and shown via body.dash-editing CSS. #} +
+
+
+ + {% if can_edit %}{% endif %} + {{ item.title }} + + + Details → + {% if can_edit %}{% endif %} + +
+
+
+
diff --git a/steward/templates/dashboard/_widget_drawer.html b/steward/templates/dashboard/_widget_drawer.html new file mode 100644 index 0000000..f5a9ecf --- /dev/null +++ b/steward/templates/dashboard/_widget_drawer.html @@ -0,0 +1,78 @@ +{# Bottom drawer — the widget picker, revealed by body.dash-editing. Fixed-position + overlay so it never changes the dashboard's width. Add forms are submitted via + JS (see index.html) which inserts the returned grid item in place. #} +
+
+
Add a widget
+ +
+ {% if addable %} + {% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %} + {% for _gkey, _glabel in _groups %} + {% set _gw = addable | selectattr("group", "equalto", _gkey) | list %} + {% if _gw %} +
{{ _glabel }}
+
+ {% for w in _gw %} +
+
+ +
+
{{ w.label }}
+
{{ w.description }}
+
+ Add ▾ +
+
+
+
+ + +
+ {% for p in w.params %} +
+ + {% if p.type == "select" %} + + {% elif p.type == "host" %} + + {% endif %} +
+ {% endfor %} + +
+
+
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} + {% else %} +
+ No plugin widgets available. Enable plugins in Settings → Plugins. +
+ {% endif %} +
diff --git a/steward/templates/dashboard/edit.html b/steward/templates/dashboard/edit.html deleted file mode 100644 index ad371c9..0000000 --- a/steward/templates/dashboard/edit.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% from "_macros.html" import crumbs %} -{% block title %}Edit: {{ dashboard.name }} — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Dashboard", "/"), ("Dashboards", "/dashboards/"), ("Edit " ~ dashboard.name, "")]) }}{% endblock %} -{% block head %} - -{% endblock %} -{% block content %} -
-

Edit: {{ dashboard.name }}

- Done -
-{% include "dashboard/_edit_panels.html" %} -{% endblock %} -{% block extra_scripts %} - - - -{% endblock %} diff --git a/steward/templates/dashboard/index.html b/steward/templates/dashboard/index.html index 317bb23..6feadac 100644 --- a/steward/templates/dashboard/index.html +++ b/steward/templates/dashboard/index.html @@ -1,5 +1,10 @@ {% extends "base.html" %} {% block title %}{{ dashboard.name }} — Steward{% endblock %} +{% block head %} +{# Gridstack renders the grid for everyone (static for viewers); editors also get + the in-place drag-resize + bottom drawer. #} + +{% endblock %} {% block content %} {# ── Header ────────────────────────────────────────────────────────────────── #} @@ -21,7 +26,7 @@ {% endif %} {% if can_edit %} - Edit + Share {% endif %} Dashboards @@ -60,31 +65,102 @@ -{# ── Widget grid ───────────────────────────────────────────────────────────── #} -{% if widgets %} -
- {% for item in widgets %} - {% set gw = item.db %} -
-
- {{ item.title }} - Details → -
-
-
-
- {% endfor %} -
-{% else %} -
+{# ── Widget grid (Gridstack; static until Edit is toggled) ──────────────────── #} +{% if not widgets %} +
No widgets on this dashboard yet.
{% if can_edit %} - Add Widgets + {% endif %}
{% endif %} +
+ {% for item in widgets %} + {% include "dashboard/_grid_item.html" %} + {% endfor %} +
+ +{% if can_edit %}{% include "dashboard/_widget_drawer.html" %}{% endif %} +{% endblock %} + +{% block extra_scripts %} + + {% endblock %} diff --git a/steward/templates/dashboard/list.html b/steward/templates/dashboard/list.html index 213e1ef..a13aec7 100644 --- a/steward/templates/dashboard/list.html +++ b/steward/templates/dashboard/list.html @@ -32,7 +32,7 @@
- Edit + Edit
Rename @@ -99,7 +99,7 @@ {# Admins can also edit system dashboards #} {% if user_role == 'admin' %} - Edit + Edit {% endif %}