Files
FabledSteward/steward/templates/dashboard/index.html
T
bvandeusenandClaude Opus 5.5 8eedae365b
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m1s
CI / unit (push) Failing after 1m19s
CI / publish (push) Skipped
fix(dashboard): heights fit content to 10px, not a whole 70px row; text columns read from the left (#5214)
View mode splits each saved row into seven, so a widget no longer carries up
to 69px of empty space below its content (Overview, SNMP). Edit mode and the
saved layout keep 70px rows. The Busiest Containers host column aligns left
like its header, its note fits one line, and the Hosts column reads Disk.

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

199 lines
8.8 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.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, 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 %}