Files
FabledSteward/steward/templates/dashboard/index.html
T
bvandeusenandClaude Opus 5.5 4dcf958022
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m18s
CI / publish (push) Successful in 1m28s
feat(dashboard): widgets fit their content; wide screens get more grid columns (#5249)
Each widget is as tall as its content, never taller than its saved height
(where it scrolls). The dashboard is capped at 2400px instead of 1400px.
Past 12 columns' worth of width it adds columns (one per 110px, up to 24)
and places widgets again in reading order at their saved widths, so more sit
side by side instead of stretching. Edit mode shows and saves the
12-column layout, so a layout doesn't depend on the screen it was edited on.

The share view uses the same layout code (static/dash-layout.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:10:42 -04:00

197 lines
8.7 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 grid = GridStack.init({
column: 12, cellHeight: CELL_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.saved(el);
s.id = el.getAttribute('gs-id');
s.h = el._fitH || s.h;
return s;
}), cols);
grid.batchUpdate();
if (grid.getColumn() !== cols) grid.column(cols, 'none');
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, CELL_PX, 2 * MARGIN_PX, L.saved(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.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) { 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 %}