feat: drag-and-drop dashboard editing, alert dynamic fields
- Replace up/down arrow reorder buttons on dashboard edit page with
SortableJS drag handles; add POST /d/<id>/edit/reorder endpoint
accepting ordered widget ID array
- Add {% block extra_scripts %} hook to base.html for page-specific JS
- Add dynamic alert rule field loading via HTMX partial (_rule_fields)
so metric/threshold fields update when source is changed
- Add docs/plugins/index.yaml.example showing catalog index format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Fabled Scryer{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
@@ -214,12 +215,14 @@ textarea { resize: vertical; }
|
||||
</a>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/hosts/">Hosts</a>
|
||||
<a href="/hosts/uptime">Uptime</a>
|
||||
<a href="/ping/">Ping</a>
|
||||
<a href="/dns/">DNS</a>
|
||||
<a href="/alerts/">Alerts</a>
|
||||
<a href="/ansible/">Ansible</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<a href="/settings/">Settings</a>
|
||||
<a href="/audit/">Audit</a>
|
||||
{% endif %}
|
||||
<a href="/logout" class="nav-end">{{ session.username }}</a>
|
||||
</nav>
|
||||
@@ -351,5 +354,7 @@ function setTimeRange(val) {
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
{# 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 320px;gap:1.5rem;align-items:start;">
|
||||
<div id="edit-panels" style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start;">
|
||||
|
||||
{# ── Current layout ───────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
|
||||
{% if widgets %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
<div id="widget-sort-list" data-dash-id="{{ dashboard.id }}" style="display:grid;gap:0.5rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card" style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
<div class="card" data-widget-id="{{ item.db.id }}"
|
||||
style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:2px;flex-shrink:0;">
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/up"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-ghost btn-sm"
|
||||
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
|
||||
{% if loop.first %}disabled{% endif %}>▲</button>
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/down"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-ghost btn-sm"
|
||||
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
|
||||
{% if loop.last %}disabled{% endif %}>▼</button>
|
||||
</div>
|
||||
<div class="drag-handle" style="flex-shrink:0;cursor:grab;color:var(--text-muted);
|
||||
font-size:1.1rem;line-height:1;user-select:none;"
|
||||
title="Drag to reorder">⠿</div>
|
||||
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.9rem;">{{ item.defn.label }}</div>
|
||||
<div style="font-weight:600;font-size:0.9rem;">{{ item.title }}</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div>
|
||||
</div>
|
||||
|
||||
@@ -47,22 +39,67 @@
|
||||
{% if addable %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for w in addable %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
|
||||
<div>
|
||||
<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>
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-sm" style="flex-shrink:0;">Add</button>
|
||||
</div>
|
||||
<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>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
|
||||
All available widgets are on this dashboard.
|
||||
No plugin widgets available. Enable plugins in Settings → Plugins.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,3 +7,37 @@
|
||||
</div>
|
||||
{% include "dashboard/_edit_panels.html" %}
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function initSortable() {
|
||||
var el = document.getElementById('widget-sort-list');
|
||||
if (!el) return;
|
||||
var dashId = el.dataset.dashId;
|
||||
Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd: function () {
|
||||
var order = Array.from(el.querySelectorAll('[data-widget-id]'))
|
||||
.map(function (row) { return parseInt(row.dataset.widgetId, 10); });
|
||||
fetch('/d/' + dashId + '/edit/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order: order }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Init on first load and after every HTMX swap (add/remove re-renders the list)
|
||||
document.addEventListener('DOMContentLoaded', initSortable);
|
||||
document.addEventListener('htmx:afterSwap', initSortable);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.sortable-ghost { opacity: 0.4; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -66,11 +66,11 @@
|
||||
{% for item in widgets %}
|
||||
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="widget-header">
|
||||
<span class="widget-title">{{ item.defn.label }}</span>
|
||||
<span class="widget-title">{{ item.title }}</span>
|
||||
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
|
||||
</div>
|
||||
<div id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.defn.hx_url }}"
|
||||
hx-get="{{ item.hx_url }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ dashboard.name }} — Fabled Scryer</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
@@ -75,11 +76,11 @@
|
||||
{% for item in widgets %}
|
||||
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="widget-header">
|
||||
<span class="widget-title">{{ item.defn.label }}</span>
|
||||
<span class="widget-title">{{ item.title }}</span>
|
||||
</div>
|
||||
{# Pass share token as ?s= param so require_role allows through #}
|
||||
{# Pass share token as &s= param so require_role allows through #}
|
||||
<div id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.defn.hx_url }}?s={{ raw_token }}"
|
||||
hx-get="{{ item.hx_url }}&s={{ raw_token }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user