diff --git a/steward/static/dash-layout.js b/steward/static/dash-layout.js new file mode 100644 index 0000000..36678e3 --- /dev/null +++ b/steward/static/dash-layout.js @@ -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, + }; +})(); diff --git a/steward/static/steward.css b/steward/static/steward.css index e49fecb..b654537 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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) { diff --git a/steward/templates/base.html b/steward/templates/base.html index fce5e31..448e6a7 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -113,7 +113,7 @@ document.addEventListener('keydown', function (e) { }); -
+ {% if plugin_failures and session.user_role == 'admin' %}
+ 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 }}">
diff --git a/steward/templates/dashboard/index.html b/steward/templates/dashboard/index.html index 3244f4f..7731738 100644 --- a/steward/templates/dashboard/index.html +++ b/steward/templates/dashboard/index.html @@ -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 %} + + {# 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. #} @@ -42,7 +41,8 @@
{% for item in widgets %} {% set gw = item.db %} -
{{ item.title }} @@ -63,5 +63,41 @@ {% endif %}
+ diff --git a/tests/core/test_dashboard_defaults.py b/tests/core/test_dashboard_defaults.py index 2ee3087..9fe3e7f 100644 --- a/tests/core/test_dashboard_defaults.py +++ b/tests/core/test_dashboard_defaults.py @@ -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 diff --git a/tests/test_share_view.py b/tests/test_share_view.py index 519bd5f..5966398 100644 --- a/tests/test_share_view.py +++ b/tests/test_share_view.py @@ -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