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
@@ -1,127 +0,0 @@
{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #}
<div id="edit-panels" style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start;">
{# ── Current layout (drag to move, drag the corner/edge to resize) ──────── #}
<div style="min-width:0;">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;">
<div class="section-title" style="margin-bottom:0;">Current Layout</div>
{% if widgets %}
<span style="font-size:0.75rem;color:var(--text-dim);">Drag to move · drag a corner to resize · saved automatically</span>
{% endif %}
</div>
{% if widgets %}
<div class="grid-stack" data-dash-id="{{ dashboard.id }}">
{% for item in widgets %}
<div class="grid-stack-item" gs-id="{{ item.db.id }}"
gs-x="{{ item.db.grid_x }}" gs-y="{{ item.db.grid_y }}"
gs-w="{{ item.db.grid_w }}" gs-h="{{ item.db.grid_h }}">
<div class="grid-stack-item-content edit-panel">
<div class="panel-drag" title="Drag to move">
<span style="color:var(--text-muted);"></span>
<span style="flex:1;min-width:0;font-weight:600;font-size:0.875rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="{{ item.title }}">{{ item.title }}</span>
<button hx-post="/d/{{ dashboard.id }}/edit/remove/{{ item.db.id }}"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-danger btn-sm" style="flex-shrink:0;">Remove</button>
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.35rem;overflow:hidden;">{{ item.defn.description }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;font-size:0.9rem;">
No widgets yet. Add one from the right to begin.
</div>
{% endif %}
</div>
{# ── Available widgets ────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Available Widgets</div>
{% 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 %}
<div style="font-size:0.72rem;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-dim);margin:0.85rem 0 0.35rem;">{{ _glabel }}</div>
<div style="display:grid;gap:0.5rem;">
{% for w in _gw %}
<div class="card" style="margin-bottom:0;padding:0;overflow:hidden;">
<details>
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
padding:0.85rem 1rem;cursor:pointer;list-style:none;user-select:none;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div>
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
</summary>
<form method="post"
action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-target="#edit-panels"
hx-swap="outerHTML"
style="padding:0.75rem 1rem;padding-top:0;border-top:1px solid var(--border);">
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
Widget title <span style="color:var(--text-dim);">(optional)</span>
</label>
<input type="text" name="title" placeholder="{{ w.label }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
</div>
{% for p in w.params %}
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
{{ p.label }}
</label>
{% if p.type == "select" %}
<select name="param_{{ p.key }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
{% for opt in p.options %}
<option value="{{ opt.value }}" {% if opt.value == p.default %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
</select>
{% elif p.type == "host" %}
<select name="param_{{ p.key }}" required
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
<option value="">— select a host —</option>
{% for h in hosts %}
<option value="{{ h.id }}">{{ h.name }}</option>
{% endfor %}
</select>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-sm" style="width:100%;margin-top:0.1rem;">
Add to Dashboard
</button>
</div>
</form>
</details>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
No plugin widgets available. Enable plugins in Settings → Plugins.
</div>
{% endif %}
</div>
</div>
@@ -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. #}
<div class="grid-stack-item" gs-id="{{ item.db.id }}"
gs-x="{{ item.db.grid_x }}" gs-y="{{ item.db.grid_y }}"
gs-w="{{ item.db.grid_w }}" gs-h="{{ item.db.grid_h }}">
<div class="grid-stack-item-content">
<div class="widget-header">
<span style="display:flex;align-items:center;gap:0.4rem;min-width:0;">
{% if can_edit %}<span class="panel-chrome panel-drag" title="Drag to move"></span>{% endif %}
<span class="widget-title" title="{{ item.title }}">{{ item.title }}</span>
</span>
<span style="display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
{% if can_edit %}<button type="button" class="panel-chrome btn btn-danger btn-sm"
data-remove="{{ item.db.id }}" title="Remove widget"
style="padding:0.05rem 0.4rem;line-height:1.3;"></button>{% endif %}
</span>
</div>
<div class="dash-cell-body" id="widget-{{ item.db.id }}"
hx-get="{{ item.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML"></div>
</div>
</div>
@@ -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. #}
<div class="widget-drawer" id="widget-drawer" aria-label="Add a widget">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;
position:sticky;top:0;background:var(--bg-card);padding-bottom:0.5rem;">
<div class="section-title" style="margin-bottom:0;">Add a widget</div>
<button type="button" class="btn btn-sm" id="dash-edit-done">Done</button>
</div>
{% 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 %}
<div style="font-size:0.72rem;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-dim);margin:0.6rem 0 0.35rem;">{{ _glabel }}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.5rem;">
{% for w in _gw %}
<div class="card" style="margin-bottom:0;padding:0;overflow:hidden;">
<details>
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.6rem;
padding:0.7rem 0.85rem;cursor:pointer;list-style:none;user-select:none;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.85rem;">{{ w.label }}</div>
<div style="font-size:0.74rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div>
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
</summary>
<form action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}" method="post"
class="add-widget-form"
style="padding:0.75rem 0.85rem;padding-top:0;border-top:1px solid var(--border);">
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
<div>
<label style="font-size:0.74rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
Widget title <span style="color:var(--text-dim);">(optional)</span>
</label>
<input type="text" name="title" placeholder="{{ w.label }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
</div>
{% for p in w.params %}
<div>
<label style="font-size:0.74rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">{{ p.label }}</label>
{% if p.type == "select" %}
<select name="param_{{ p.key }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.85rem;">
{% for opt in p.options %}
<option value="{{ opt.value }}" {% if opt.value == p.default %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
{% elif p.type == "host" %}
<select name="param_{{ p.key }}" required
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.85rem;">
<option value="">— select a host —</option>
{% for h in hosts %}
<option value="{{ h.id }}">{{ h.name }}</option>
{% endfor %}
</select>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-sm" style="width:100%;margin-top:0.1rem;">Add to dashboard</button>
</div>
</form>
</details>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No plugin widgets available. Enable plugins in Settings → Plugins.
</div>
{% endif %}
</div>
-58
View File
@@ -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 %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/gridstack@12.6.0/dist/gridstack.min.css">
{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Edit: {{ dashboard.name }}</h1>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Done</a>
</div>
{% include "dashboard/_edit_panels.html" %}
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/gridstack@12.6.0/dist/gridstack-all.min.js"></script>
<script>
(function () {
// (Re)initialise the drag-resize grid. Called on first load and after every
// HTMX swap (add/remove re-renders #edit-panels with fresh markup, so the old
// grid DOM is gone and we bind to the new container).
function initGrid() {
var el = document.querySelector('.grid-stack');
if (!el || el.gridstack) return; // already bound to this node
var dashId = el.dataset.dashId;
var grid = GridStack.init({
column: 12,
cellHeight: 70,
margin: 8,
float: false, // compact upward, no floating gaps
handle: '.panel-drag', // drag by the title bar; buttons stay clickable
resizable: { handles: 'e, se, s' },
}, el);
function save() {
var items = grid.save(false).map(function (n) {
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
});
fetch('/d/' + dashId + '/edit/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: items }),
});
}
grid.on('change', save);
}
document.addEventListener('DOMContentLoaded', initGrid);
document.addEventListener('htmx:afterSwap', initGrid);
})();
</script>
<style>
/* Each Gridstack panel reads as a card; the title bar is the drag handle. */
.edit-panel { background:var(--bg-card); border:1px solid var(--border-mid); border-radius:6px;
padding:0.7rem 0.85rem; height:100%; overflow:hidden; }
.panel-drag { display:flex; align-items:center; gap:0.5rem; cursor:grab; user-select:none; }
.panel-drag:active { cursor:grabbing; }
.grid-stack-item-content { inset:0; }
</style>
{% endblock %}
+99 -23
View File
@@ -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. #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/gridstack@12.6.0/dist/gridstack.min.css">
{% endblock %}
{% block content %}
{# ── Header ────────────────────────────────────────────────────────────────── #}
@@ -21,7 +26,7 @@
</select>
{% endif %}
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<button type="button" id="dash-edit-toggle" class="btn btn-ghost btn-sm">Edit</button>
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
{% endif %}
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
@@ -60,31 +65,102 @@
</div>
</div>
{# ── Widget grid ───────────────────────────────────────────────────────────── #}
{% if widgets %}
<div class="dash-grid">
{% for item in widgets %}
{% set gw = item.db %}
<div class="card dash-cell"
style="grid-column:{{ gw.grid_x + 1 }} / span {{ gw.grid_w }};grid-row:{{ gw.grid_y + 1 }} / span {{ gw.grid_h }};">
<div class="widget-header">
<span class="widget-title" title="{{ item.title }}">{{ item.title }}</span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
</div>
<div class="dash-cell-body" id="widget-{{ gw.id }}"
hx-get="{{ item.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
{# ── Widget grid (Gridstack; static until Edit is toggled) ──────────────────── #}
{% if not widgets %}
<div id="dash-empty" class="card" style="text-align:center;padding:3rem;">
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn">Add Widgets</a>
<button type="button" class="btn" onclick="document.getElementById('dash-edit-toggle').click()">Add Widgets</button>
{% endif %}
</div>
{% endif %}
<div class="grid-stack" data-dash-id="{{ dashboard.id }}" data-can-edit="{{ '1' if can_edit else '0' }}">
{% for item in widgets %}
{% include "dashboard/_grid_item.html" %}
{% endfor %}
</div>
{% if can_edit %}{% include "dashboard/_widget_drawer.html" %}{% endif %}
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/gridstack@12.6.0/dist/gridstack-all.min.js"></script>
<script>
(function () {
var gridEl = document.querySelector('.grid-stack');
if (!gridEl || typeof GridStack === 'undefined') return;
var dashId = gridEl.dataset.dashId;
var canEdit = gridEl.dataset.canEdit === '1';
var editing = false;
var grid = GridStack.init({
column: 12, cellHeight: 70, margin: 8, float: false,
staticGrid: true, // view mode: positioned but inert
handle: '.panel-drag',
resizable: { handles: 'e, se, s' },
columnOpts: { breakpointForWindow: true, breakpoints: [{ w: 700, c: 1 }] },
}, gridEl);
if (!canEdit) return; // viewers get the static grid only
function saveLayout() {
var items = grid.save(false).map(function (n) {
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
});
fetch('/d/' + dashId + '/edit/layout', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: items }),
});
}
grid.on('change', function () { if (editing) saveLayout(); });
function setEditing(on) {
editing = on;
grid.setStatic(!on);
document.body.classList.toggle('dash-editing', on);
var btn = document.getElementById('dash-edit-toggle');
if (btn) btn.textContent = on ? 'Done' : 'Edit';
}
var toggle = document.getElementById('dash-edit-toggle');
if (toggle) toggle.addEventListener('click', function () { setEditing(!editing); });
var done = document.getElementById('dash-edit-done');
if (done) done.addEventListener('click', function () { setEditing(false); });
// Add widget — POST the form, insert the returned grid item in place.
document.querySelectorAll('.add-widget-form').forEach(function (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
fetch(form.action, { method: 'POST', body: new FormData(form) })
.then(function (r) { return r.status === 204 ? '' : r.text(); })
.then(function (html) {
if (!html) return;
var tmp = document.createElement('div');
tmp.innerHTML = html.trim();
var el = tmp.firstElementChild;
if (!el) return;
gridEl.appendChild(el);
grid.makeWidget(el);
if (window.htmx) window.htmx.process(el); // load its live data
var empty = document.getElementById('dash-empty');
if (empty) empty.style.display = 'none';
var det = form.closest('details');
if (det) det.open = false;
});
});
});
// Remove widget — delegated; POST then drop the grid item.
gridEl.addEventListener('click', function (ev) {
var rm = ev.target.closest('[data-remove]');
if (!rm) return;
ev.preventDefault();
var item = rm.closest('.grid-stack-item');
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove, { method: 'POST' })
.then(function () { if (item) grid.removeWidget(item); });
});
// Deep link: /d/<id>?edit=1 opens straight into edit mode (new dashboards do this).
if (new URLSearchParams(window.location.search).get('edit') === '1') setEditing(true);
})();
</script>
{% endblock %}
+2 -2
View File
@@ -32,7 +32,7 @@
</div>
</div>
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<a href="/d/{{ dash.id }}?edit=1" class="btn btn-ghost btn-sm">Edit</a>
<details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
@@ -99,7 +99,7 @@
</form>
{# Admins can also edit system dashboards #}
{% if user_role == 'admin' %}
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<a href="/d/{{ dash.id }}?edit=1" class="btn btn-ghost btn-sm">Edit</a>
{% endif %}
</div>
</div>