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>
199 lines
8.8 KiB
HTML
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 %}
|