Wider than 12 columns, pack() took the first free spot for each widget's content-fitted height, and that height changes as each widget's content arrives and on every poll, so widgets traded columns and order as the page loaded (CI 9107: test_dash_layout fails at 16, 18 and 24 columns on the old pack()). Now columns and order are decided at the saved heights, from the saved layout and the column count alone; each widget then drops at its fitted height onto what is above it in its columns, so content only closes gaps. The dashboard moves widgets into place in two passes, below everything first and then up, so Gridstack never pushes a not-yet-moved widget out of its place on the way. The share view passes saved heights the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
9.5 KiB
HTML
213 lines
9.5 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ dashboard.name }} — Steward{% endblock %}
|
|
{% block main_class %} class="main-wide"{% 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="{{ url_for('static', filename='vendor/gridstack-12.6.0.min.css') }}">
|
|
{% endblock %}
|
|
{% block content %}
|
|
|
|
{# ── Header ────────────────────────────────────────────────────────────────── #}
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
|
<h1 class="page-title" style="margin-bottom:0;">{{ dashboard.name }}</h1>
|
|
{% if dashboard.is_default %}
|
|
<span class="badge badge-gold" style="font-size:0.7rem;">Default</span>
|
|
{% endif %}
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
|
{# Dashboard switcher — shown when more than one dashboard exists #}
|
|
{% if all_dashboards | length > 1 %}
|
|
<select onchange="window.location='/d/'+this.value" class="input" style="width:auto;" aria-label="Dashboard">
|
|
{% for d in all_dashboards %}
|
|
<option value="{{ d.id }}" {% if d.id == dashboard.id %}selected{% endif %}>{{ d.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
{% if can_edit %}
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
{# The old fixed summary strip (Hosts/Ping/DNS/Alerts) was merged into the single
|
|
'Overview' widget (status_overview) so there's one canonical summary, not two. #}
|
|
|
|
{# ── 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 %}
|
|
<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="{{ url_for('static', filename='vendor/gridstack-12.6.0-all.min.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='dash-cells.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='dash-layout.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; // edit mode: the saved layout, draggable
|
|
|
|
var L = window.StewardDashLayout;
|
|
var CELL_PX = 70, MARGIN_PX = 8;
|
|
var VIEW_PX = CELL_PX / L.ROW_SPLIT; // view mode: finer rows, so heights fit closely
|
|
var grid = GridStack.init({
|
|
column: 12, cellHeight: VIEW_PX, margin: MARGIN_PX, float: false,
|
|
staticGrid: true, // view mode: positioned but inert
|
|
handle: '.panel-drag',
|
|
resizable: { handles: 'e, se, s' },
|
|
}, gridEl);
|
|
|
|
function items() { return Array.prototype.slice.call(gridEl.querySelectorAll('.grid-stack-item')); }
|
|
|
|
// View mode: each widget at its content height (never above its saved one),
|
|
// placed for this screen's column count (static/dash-layout.js).
|
|
function relayout() {
|
|
if (editing) return;
|
|
var cols = L.columnsFor(gridEl.clientWidth);
|
|
var els = items();
|
|
var pos = L.pack(els.map(function (el) {
|
|
var s = L.viewed(el);
|
|
s.id = el.getAttribute('gs-id');
|
|
s.full = s.h;
|
|
s.h = el._fitH || s.h;
|
|
return s;
|
|
}), cols);
|
|
grid.batchUpdate();
|
|
if (grid.getColumn() !== cols) grid.column(cols, 'none');
|
|
// Two passes (#5713): moved into place one by one, a widget could land on
|
|
// one not moved yet, and Gridstack would push that one down out of its
|
|
// place. So everything first goes below the lowest widget, keeping its
|
|
// new shape, then up into place, where nothing is in the way.
|
|
var below = 0;
|
|
els.forEach(function (el) {
|
|
var n = el.gridstackNode, p = pos[el.getAttribute('gs-id')];
|
|
below = Math.max(below, p.y + p.h, n ? n.y + n.h : 0);
|
|
});
|
|
els.forEach(function (el) {
|
|
var p = pos[el.getAttribute('gs-id')];
|
|
grid.update(el, { x: p.x, y: below + p.y, w: p.w, h: p.h });
|
|
});
|
|
els.forEach(function (el) { grid.update(el, pos[el.getAttribute('gs-id')]); });
|
|
grid.batchUpdate(false);
|
|
}
|
|
var pending = null;
|
|
function relayoutSoon() { clearTimeout(pending); pending = setTimeout(relayout, 60); }
|
|
|
|
function fit(el) {
|
|
var box = el.querySelector('.grid-stack-item-content');
|
|
if (!box) return;
|
|
var rows = L.fitRows(box, VIEW_PX, 2 * MARGIN_PX, L.viewed(el).h);
|
|
if (rows !== el._fitH) { el._fitH = rows; relayoutSoon(); }
|
|
}
|
|
document.body.addEventListener('htmx:afterSettle', function (e) {
|
|
var el = e.detail.target && e.detail.target.closest && e.detail.target.closest('.grid-stack-item');
|
|
if (el && !editing) fit(el);
|
|
});
|
|
// A new column count changes widths, so text wraps differently: fit again.
|
|
window.addEventListener('resize', function () {
|
|
clearTimeout(window._dashResize);
|
|
window._dashResize = setTimeout(function () { relayout(); items().forEach(fit); }, 150);
|
|
});
|
|
relayout();
|
|
|
|
if (!canEdit) return; // viewers get the static grid only
|
|
var csrf = document.querySelector('meta[name="csrf-token"]').content;
|
|
|
|
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 };
|
|
});
|
|
items.forEach(function (n) { // what's saved is the new cap
|
|
var el = gridEl.querySelector('.grid-stack-item[gs-id="' + n.id + '"]');
|
|
if (el) { el.dataset.x = n.x; el.dataset.y = n.y; el.dataset.w = n.w; el.dataset.h = n.h; }
|
|
});
|
|
fetch('/d/' + dashId + '/edit/layout', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
|
body: JSON.stringify({ items: items }),
|
|
});
|
|
}
|
|
grid.on('change', function () { if (editing) saveLayout(); });
|
|
|
|
// Edit mode shows the saved 12-column layout at saved heights, so a layout
|
|
// edited on a wide screen is the same one a narrow screen starts from.
|
|
function showSaved() {
|
|
grid.cellHeight(CELL_PX);
|
|
grid.batchUpdate();
|
|
if (grid.getColumn() !== L.MIN_COLS) grid.column(L.MIN_COLS, 'none');
|
|
items().forEach(function (el) { grid.update(el, L.saved(el)); });
|
|
grid.batchUpdate(false);
|
|
}
|
|
|
|
function setEditing(on) {
|
|
if (on) showSaved(); // before the flag: restoring isn't an edit to save
|
|
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';
|
|
if (!on) { grid.cellHeight(VIEW_PX); items().forEach(fit); relayout(); }
|
|
}
|
|
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), headers: { 'X-CSRF-Token': csrf } })
|
|
.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', headers: { 'X-CSRF-Token': csrf } })
|
|
.then(function () { if (item) grid.removeWidget(item); relayoutSoon(); });
|
|
});
|
|
|
|
// 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 %}
|