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>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/* Dashboard layout (#5249): each widget as tall as its content, never taller
|
||||
than the height saved for it, and extra grid columns on wide screens.
|
||||
|
||||
The layout is saved in 12 columns. A viewer on a wider screen gets more
|
||||
columns (one per COL_PX of grid width, up to MAX_COLS), and the widgets are
|
||||
placed again in reading order at their saved widths, so more of them sit
|
||||
side by side; none is stretched. Edit mode always shows the saved 12-column
|
||||
layout, so what is saved never depends on the screen it was edited on.
|
||||
|
||||
Used by the dashboard (Gridstack) and the share view (CSS grid). */
|
||||
(function () {
|
||||
var COL_PX = 110, MIN_COLS = 12, MAX_COLS = 24, NARROW_PX = 700, MIN_ROWS = 2;
|
||||
|
||||
function columnsFor(width) {
|
||||
if (width < NARROW_PX) return 1;
|
||||
return Math.max(MIN_COLS, Math.min(MAX_COLS, Math.floor(width / COL_PX)));
|
||||
}
|
||||
|
||||
/* items: [{id, x, y, w, h}] from the saved layout; h is the height to place
|
||||
(the content-fitted one). Returns {id: {x, y, w, h}}.
|
||||
|
||||
Reading order (y, then x) is kept: nothing is placed above the item before
|
||||
it. At 12 columns an item keeps its saved column and floats up, which is the
|
||||
saved layout minus the space short widgets no longer need. Narrower or wider,
|
||||
it takes the first free spot. */
|
||||
function pack(items, cols) {
|
||||
var taken = [];
|
||||
function free(x, y, w, h) {
|
||||
for (var r = y; r < y + h; r++) {
|
||||
if (!taken[r]) continue;
|
||||
for (var c = x; c < x + w; c++) if (taken[r][c]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function take(x, y, w, h) {
|
||||
for (var r = y; r < y + h; r++) {
|
||||
taken[r] = taken[r] || [];
|
||||
for (var c = x; c < x + w; c++) taken[r][c] = true;
|
||||
}
|
||||
}
|
||||
var out = {}, minY = 0;
|
||||
items.slice().sort(function (a, b) { return a.y - b.y || a.x - b.x; }).forEach(function (it) {
|
||||
var w = Math.min(it.w, cols), h = it.h, x = 0, y;
|
||||
if (cols === MIN_COLS) {
|
||||
x = Math.min(it.x, cols - w);
|
||||
for (y = 0; !free(x, y, w, h); y++) { /* float up as far as it fits */ }
|
||||
} else {
|
||||
search:
|
||||
for (y = minY; ; y++) {
|
||||
for (x = 0; x + w <= cols; x++) if (free(x, y, w, h)) break search;
|
||||
}
|
||||
}
|
||||
take(x, y, w, h);
|
||||
minY = y;
|
||||
out[it.id] = { x: x, y: y, w: w, h: h };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The height a widget's box needs to show everything in it: its padding and
|
||||
border, plus each child (header, body) at its natural height. The body
|
||||
normally fills the box and scrolls, so it is measured unstretched. */
|
||||
function naturalHeight(box) {
|
||||
var body = box.querySelector('.dash-cell-body');
|
||||
var cs = getComputedStyle(box);
|
||||
var h = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom)
|
||||
+ parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
||||
if (body) { body.style.flex = '0 0 auto'; body.style.overflow = 'visible'; }
|
||||
Array.prototype.forEach.call(box.children, function (child) {
|
||||
var s = getComputedStyle(child);
|
||||
h += child.offsetHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom);
|
||||
});
|
||||
if (body) { body.style.flex = ''; body.style.overflow = ''; }
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Grid rows for that height: rowPx is one row plus the gap that follows it,
|
||||
gapPx the space a cell loses to its margins. Capped at the saved height,
|
||||
where the body scrolls instead. */
|
||||
function fitRows(box, rowPx, gapPx, maxRows) {
|
||||
var rows = Math.ceil((naturalHeight(box) + gapPx) / rowPx);
|
||||
return Math.max(MIN_ROWS, Math.min(maxRows, rows));
|
||||
}
|
||||
|
||||
function saved(el) {
|
||||
return { x: +el.dataset.x, y: +el.dataset.y, w: +el.dataset.w, h: +el.dataset.h };
|
||||
}
|
||||
|
||||
window.StewardDashLayout = {
|
||||
MIN_COLS: MIN_COLS, columnsFor: columnsFor, pack: pack, fitRows: fitRows, saved: saved,
|
||||
};
|
||||
})();
|
||||
@@ -117,6 +117,9 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
|
||||
|
||||
/* Layout */
|
||||
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; min-width: 0; box-sizing: border-box; position: relative; z-index: 1; }
|
||||
/* The dashboard keeps a cap too, a wider one: past 12 columns' worth it adds
|
||||
grid columns rather than stretching widgets (static/dash-layout.js). */
|
||||
main.main-wide { max-width: 2400px; }
|
||||
|
||||
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -113,7 +113,7 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<main{% block main_class %}{% endblock %}>
|
||||
{% if plugin_failures and session.user_role == 'admin' %}
|
||||
<div style="background:color-mix(in srgb,var(--yellow) 12%,var(--bg-elevated));
|
||||
border:1px solid color-mix(in srgb,var(--yellow) 35%,var(--border));
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{# 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. #}
|
||||
{# data-x/y/w/h are the saved 12-column layout; static/dash-layout.js fits
|
||||
each widget to its content and adds columns on wide screens from them. #}
|
||||
<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 }}">
|
||||
gs-w="{{ item.db.grid_w }}" gs-h="{{ item.db.grid_h }}"
|
||||
data-x="{{ item.db.grid_x }}" data-y="{{ item.db.grid_y }}"
|
||||
data-w="{{ item.db.grid_w }}" data-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;">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% 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. #}
|
||||
@@ -56,22 +57,63 @@
|
||||
{% 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;
|
||||
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: 70, margin: 8, float: false,
|
||||
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' },
|
||||
columnOpts: { breakpointForWindow: true, breakpoints: [{ w: 700, c: 1 }] },
|
||||
}, 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;
|
||||
|
||||
@@ -79,6 +121,10 @@
|
||||
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 }),
|
||||
@@ -86,12 +132,23 @@
|
||||
}
|
||||
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); });
|
||||
@@ -129,7 +186,7 @@
|
||||
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); });
|
||||
.then(function () { if (item) grid.removeWidget(item); relayoutSoon(); });
|
||||
});
|
||||
|
||||
// Deep link: /d/<id>?edit=1 opens straight into edit mode (new dashboards do this).
|
||||
|
||||
@@ -12,20 +12,19 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
|
||||
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='dash-cells.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='dash-layout.js') }}"></script>
|
||||
{# The palette, type and widget classes come from steward.css like every
|
||||
other page; only the read-only page's own frame and its grid live here. #}
|
||||
<style>
|
||||
.share-header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-bottom: 1px solid var(--border-mid); min-height: 48px; }
|
||||
.share-name { font-family: var(--fs-font-display); font-weight: 500; font-size: 1.15rem; color: var(--gold); overflow-wrap: anywhere; }
|
||||
.share-main { max-width: 1600px; }
|
||||
/* Same grid as the authenticated view, rendered from x/y/w/h. */
|
||||
.share-main { max-width: 2400px; }
|
||||
/* The same layout as the signed-in dashboard: rows 70px, gaps 1rem, and
|
||||
static/dash-layout.js sets the columns and each cell's place and height. */
|
||||
.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; }
|
||||
.share-main { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +41,8 @@
|
||||
<div class="dash-grid">
|
||||
{% for item in widgets %}
|
||||
{% set gw = item.db %}
|
||||
<div class="card ping-card dash-cell"
|
||||
<div class="card ping-card dash-cell" data-id="{{ gw.id }}"
|
||||
data-x="{{ gw.grid_x }}" data-y="{{ gw.grid_y }}" data-w="{{ gw.grid_w }}" data-h="{{ gw.grid_h }}"
|
||||
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>
|
||||
@@ -63,5 +63,41 @@
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
/* Fit each cell to its content and place it for this screen's column count,
|
||||
as on the signed-in dashboard (static/dash-layout.js). */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var L = window.StewardDashLayout, grid = document.querySelector('.dash-grid');
|
||||
if (!L || !grid) return;
|
||||
var ROW_PX = 70, GAP_PX = 16;
|
||||
function cells() { return Array.prototype.slice.call(grid.querySelectorAll('.dash-cell')); }
|
||||
function relayout() {
|
||||
var cols = L.columnsFor(grid.clientWidth);
|
||||
var pos = L.pack(cells().map(function (el) {
|
||||
var s = L.saved(el); s.id = el.dataset.id; s.h = el._fitH || s.h; return s;
|
||||
}), cols);
|
||||
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
||||
cells().forEach(function (el) {
|
||||
var p = pos[el.dataset.id];
|
||||
el.style.gridColumn = (p.x + 1) + ' / span ' + p.w;
|
||||
el.style.gridRow = (p.y + 1) + ' / span ' + p.h;
|
||||
});
|
||||
}
|
||||
var pending = null;
|
||||
function fit(el) {
|
||||
var rows = L.fitRows(el, ROW_PX + GAP_PX, GAP_PX, L.saved(el).h);
|
||||
if (rows !== el._fitH) { el._fitH = rows; clearTimeout(pending); pending = setTimeout(relayout, 60); }
|
||||
}
|
||||
document.body.addEventListener('htmx:afterSettle', function (e) {
|
||||
var el = e.detail.target && e.detail.target.closest && e.detail.target.closest('.dash-cell');
|
||||
if (el) fit(el);
|
||||
});
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(window._dashResize);
|
||||
window._dashResize = setTimeout(function () { relayout(); cells().forEach(fit); }, 150);
|
||||
});
|
||||
relayout();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -106,3 +106,14 @@ async def test_a_widget_polls_at_its_own_pace(app):
|
||||
async with app.test_request_context("/"):
|
||||
page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30)
|
||||
assert "every 30s" in page
|
||||
|
||||
|
||||
async def test_a_grid_item_carries_its_saved_layout(app):
|
||||
"""#5249: the saved 12-column place and height, which the page fits to
|
||||
content in view mode and restores in edit mode."""
|
||||
item = SimpleNamespace(db=SimpleNamespace(id=7, grid_x=6, grid_y=3, grid_w=6, grid_h=5),
|
||||
title="Uptime", defn={"poll": False, "detail_url": "/x"}, hx_url="/uptime/widget")
|
||||
async with app.test_request_context("/"):
|
||||
page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30)
|
||||
assert 'data-x="6" data-y="3"' in page and 'data-w="6" data-h="5"' in page
|
||||
assert 'gs-x="6" gs-y="3"' in page
|
||||
|
||||
@@ -28,3 +28,13 @@ async def test_each_widget_loads_through_the_share_token(app):
|
||||
page = await _share_view(app, [item])
|
||||
assert 'hx-get="/w/hosts?id=7&s=t0ken"' in page
|
||||
assert "grid-column:1 / span 6;grid-row:3 / span 3;" in page and "every 30s" in page
|
||||
|
||||
|
||||
async def test_the_share_view_fits_and_widens_like_the_dashboard(app):
|
||||
"""#5249: each cell carries its saved place for static/dash-layout.js, which
|
||||
fits it to its content and adds columns on a wide screen."""
|
||||
gw = SimpleNamespace(id=7, grid_x=6, grid_y=2, grid_w=6, grid_h=3)
|
||||
item = SimpleNamespace(db=gw, title="Hosts", hx_url="/w/hosts?id=7", defn=SimpleNamespace(poll=False))
|
||||
page = await _share_view(app, [item])
|
||||
assert 'data-id="7"' in page and 'data-x="6" data-y="2" data-w="6" data-h="3"' in page
|
||||
assert "/static/dash-layout.js" in page and "StewardDashLayout" in page
|
||||
|
||||
Reference in New Issue
Block a user