feat: v0.5.6 - Node-Detail-Modal mit Minikarte im Dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-17 16:18:45 +01:00
parent 5b2a5867d5
commit 7bf58a32fb
4 changed files with 121 additions and 2 deletions

View file

@ -1,5 +1,11 @@
# Changelog
## [0.5.6] - 2026-02-17
### Added
- Node-Detail-Modal im Dashboard: Klick auf Node-Zeile oeffnet Modal mit allen Node-Daten
- Leaflet-Minikarte im Modal zeigt Node-Position (oder "Keine Position" Hinweis)
- Zwei-Spalten-Layout: Datentabelle links, Karte rechts (responsive)
## [0.5.5] - 2026-02-17
### Changed
- SMTP-Versand auf EmailMessage + aiosmtplib.SMTP (async context manager) umgestellt

View file

@ -1,4 +1,4 @@
version: "0.5.5"
version: "0.5.6"
bot:
name: "MeshDD-Bot"

View file

@ -6,6 +6,7 @@
<title>MeshDD-Bot Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
@ -162,7 +163,38 @@
</div>
</main>
<!-- Node Detail Modal -->
<div class="modal fade" id="nodeModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title d-flex align-items-center gap-2">
<span class="status-dot" id="modalStatusDot"></span>
<span id="modalNodeName">Node</span>
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-2">
<div class="row g-3">
<div class="col-md-6">
<table class="table table-sm table-borderless mb-0">
<tbody id="modalNodeDetails"></tbody>
</table>
</div>
<div class="col-md-6">
<div id="modalMapContainer" style="height:250px;border-radius:.375rem;overflow:hidden"></div>
<div id="modalNoPosition" class="text-body-secondary text-center py-5 d-none">
<i class="bi bi-geo-alt-fill fs-3 d-block mb-1"></i>Keine Position verfuegbar
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/static/js/dashboard.js"></script>
</body>
</html>

View file

@ -114,7 +114,7 @@ function renderNodes() {
const hops = node.hop_count != null ? node.hop_count : '-';
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : '-';
const onlineClass = isOnline(node.last_seen) ? 'text-success' : '';
return `<tr>
return `<tr data-node-id="${escapeHtml(node.node_id)}" style="cursor:pointer">
<td class="${onlineClass}">${escapeHtml(name)}</td>
<td class="text-body-secondary">${escapeHtml(hw)}</td>
<td class="text-end px-1">${snr}</td>
@ -263,4 +263,85 @@ if (sidebarToggle) {
sidebarBackdrop.addEventListener('click', () => sidebar.classList.remove('open'));
}
// Node Detail Modal
let nodeModalMap = null;
let nodeModalMarker = null;
const nodeModalEl = document.getElementById('nodeModal');
const nodeModal = new bootstrap.Modal(nodeModalEl);
nodesTable.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-node-id]');
if (!row) return;
showNodeModal(row.dataset.nodeId);
});
function showNodeModal(nodeId) {
const node = nodes[nodeId];
if (!node) return;
// Header
const name = node.long_name || node.short_name || node.node_id;
document.getElementById('modalNodeName').textContent = name;
const dot = document.getElementById('modalStatusDot');
if (isOnline(node.last_seen)) {
dot.classList.add('connected');
} else {
dot.classList.remove('connected');
}
// Details table
const fmt = (v) => v != null && v !== '' ? escapeHtml(String(v)) : '-';
const fmtTime = (ts) => ts ? new Date(ts * 1000).toLocaleString('de-DE') : '-';
const rows = [
['Node-ID', fmt(node.node_id)],
['Long Name', fmt(node.long_name)],
['Short Name', fmt(node.short_name)],
['Hardware', fmt(node.hw_model)],
['SNR', node.snr != null ? `${node.snr.toFixed(1)} dB` : '-'],
['RSSI', node.rssi != null ? `${node.rssi} dBm` : '-'],
['Batterie', node.battery != null ? `${node.battery}%` : '-'],
['Spannung', node.voltage != null ? `${node.voltage.toFixed(2)} V` : '-'],
['Hops', fmt(node.hop_count)],
['Via MQTT', node.via_mqtt ? 'Ja' : 'Nein'],
['Hoehe', node.alt != null ? `${node.alt} m` : '-'],
['Erste Verbindung', fmtTime(node.first_seen)],
['Letzte Verbindung', fmtTime(node.last_seen)],
];
document.getElementById('modalNodeDetails').innerHTML = rows.map(([label, val]) =>
`<tr><td class="text-body-secondary py-0 pe-2" style="white-space:nowrap">${label}</td><td class="py-0">${val}</td></tr>`
).join('');
// Map
const mapContainer = document.getElementById('modalMapContainer');
const noPos = document.getElementById('modalNoPosition');
const hasPosition = node.lat != null && node.lon != null && (node.lat !== 0 || node.lon !== 0);
if (hasPosition) {
mapContainer.classList.remove('d-none');
noPos.classList.add('d-none');
} else {
mapContainer.classList.add('d-none');
noPos.classList.remove('d-none');
}
nodeModal.show();
if (hasPosition) {
nodeModalEl.addEventListener('shown.bs.modal', function onShown() {
nodeModalEl.removeEventListener('shown.bs.modal', onShown);
if (!nodeModalMap) {
nodeModalMap = L.map(mapContainer).setView([node.lat, node.lon], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(nodeModalMap);
nodeModalMarker = L.marker([node.lat, node.lon]).addTo(nodeModalMap);
} else {
nodeModalMap.setView([node.lat, node.lon], 14);
nodeModalMarker.setLatLng([node.lat, node.lon]);
}
nodeModalMap.invalidateSize();
});
}
}
connectWebSocket();