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")
|
||||
|
||||
+21
-11
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user