Files
FabledSteward/steward/templates/dashboard/index.html
T
bvandeusenandClaude Opus 5.5 2119d20f18
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
fix(dashboard): widgets hold their place as content loads and the window resizes (#5713)
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>
2026-10-14 11:32:23 -04:00

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 %}