From d6631c1554ac5b2f13d4b952bdeae3aa3d6cb37b Mon Sep 17 00:00:00 2001 From: ppfeiffer Date: Thu, 19 Feb 2026 19:28:26 +0100 Subject: [PATCH] feat: Dashboard-Charts-Fix, Nachrichten-Seite, Legende-Hintergrund (fixes #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix: Dashboard-Charts (Kanal-Anfragen + Pakettypen) erscheinen nun initial korrekt: lastStats gecacht, updateChannelChart nach channels-Event aufgerufen; packet_type_breakdown in get_stats() ergänzt (SQL über packets-Tabelle, 24h) - Fix: Kartenlegende hat jetzt explizite Hintergrundfarben per [data-bs-theme]- Selektor (light=#fff, dark=#1e2128) – keine transparente Legende mehr - Feat: Neue Nachrichten-Seite /messages (User-only) mit Kanal-Farbcodierung und Richtungs-Kennzeichnung (empfangen=links/kanalfarbe, gesendet=rechts/grün), Channel-Filter-Tabs, Absender-Node-ID, Löschen-Button - Feat: Dashboard Nodes-Tabelle: neue Spalten RSSI und GPS-Positions-Indikator - Feat: app.js sidebar-user Klasse für eingeloggte Benutzer (non-admin) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 26 ++++++ config.yaml | 2 +- meshbot/database.py | 5 ++ meshbot/webserver.py | 4 + static/css/style.css | 98 +++++++++++++++++++++- static/index.html | 2 + static/js/app.js | 24 +++--- static/js/dashboard.js | 10 +++ static/js/messages.js | 179 +++++++++++++++++++++++++++++++++++++++++ static/messages.html | 83 +++++++++++++++++++ 10 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 static/js/messages.js create mode 100644 static/messages.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0488c..3730f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [0.8.10] - 2026-02-19 + +### Added +- **Neue Nachrichten-Seite** (`/messages`, User-only): Komplett eigenständige Seite für + den Nachrichtenverlauf mit Kanal-Farbcodierung und Richtungs-Kennzeichnung: + - Empfangene Nachrichten: links, Absenderbild (Initialen-Avatar) mit Kanalfarbe, + Bubble mit farbigem linkem Rand + - Gesendete Nachrichten (Bot): rechts, grüne Bubble mit rechtem Rand + - Kanalfilter-Buttons oben (farblich je Kanal), Löschen-Button + - Node-ID des Absenders als zweite Zeile unterhalb des Namens + - Sidebar-Eintrag „Nachrichten" nur für eingeloggte Benutzer sichtbar (`sidebar-user`) +- **Dashboard Nodes-Tabelle erweitert**: Neue Spalten RSSI (dBm) und GPS-Positions-Indikator + (grünes Stecknadel-Icon wenn Position bekannt, graues sonst) +- **`packet_type_breakdown` in Stats-API**: `GET /api/stats` und `stats_update`-WebSocket + liefern jetzt Pakettypen-Verteilung der letzten 24h aus der `packets`-Tabelle + +### Fixed +- **Dashboard Charts initial leer** (fixes #10): `updateChannelChart` wurde aufgerufen + bevor der `channels`-WS-Event ankam → Chart blieb leer. Fix: `lastStats` speichern, + Chart neu zeichnen wenn Channels eintreffen. +- **Dashboard Pakettypen-Chart immer leer** (fixes #10): `packet_type_breakdown` fehlte + komplett in `get_stats()` – jetzt per SQL-Abfrage über `packets`-Tabelle befüllt. +- **Kartenlegende transparent** (fixes #10): CSS-Variablen in Leaflet-Control-Container + wurden nicht zuverlässig aufgelöst. Fix: explizite Hintergrundfarben für Light- und + Dark-Mode per `[data-bs-theme]`-Selektor statt CSS-Variablen. + ## [0.8.9] - 2026-02-19 ### Added diff --git a/config.yaml b/config.yaml index 3a8f4cf..1340601 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -version: "0.8.9" +version: "0.8.10" bot: name: "MeshDD-Bot" diff --git a/meshbot/database.py b/meshbot/database.py index 1714982..1c26fa9 100644 --- a/meshbot/database.py +++ b/meshbot/database.py @@ -216,6 +216,11 @@ class Database: (day_ago,), ) as cursor: stats["channel_breakdown"] = {row[0]: row[1] async for row in cursor} + async with self.db.execute( + "SELECT portnum, COUNT(*) as cnt FROM packets WHERE timestamp >= ? GROUP BY portnum ORDER BY cnt DESC", + (day_ago,), + ) as cursor: + stats["packet_type_breakdown"] = {(row[0] or "?"): row[1] async for row in cursor} return stats # ── User methods ────────────────────────────────── diff --git a/meshbot/webserver.py b/meshbot/webserver.py index 69e819e..f2f5fd7 100644 --- a/meshbot/webserver.py +++ b/meshbot/webserver.py @@ -88,6 +88,7 @@ class WebServer: self.app.router.add_get("/nina", self._serve_nina) self.app.router.add_get("/map", self._serve_map) self.app.router.add_get("/packets", self._serve_packets) + self.app.router.add_get("/messages", self._serve_messages) self.app.router.add_get("/", self._serve_index) self.app.router.add_static("/static", STATIC_DIR) @@ -202,6 +203,9 @@ class WebServer: async def _serve_packets(self, request: web.Request) -> web.Response: return web.FileResponse(os.path.join(STATIC_DIR, "packets.html")) + async def _serve_messages(self, request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(STATIC_DIR, "messages.html")) + async def _serve_scheduler(self, request: web.Request) -> web.Response: return web.FileResponse(os.path.join(STATIC_DIR, "scheduler.html")) diff --git a/static/css/style.css b/static/css/style.css index aad36d3..7ed3d1f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -263,15 +263,26 @@ .node-tooltip-wrap { padding: 0; } .legend { - background: var(--tblr-bg-surface, var(--bs-body-bg, #fff)); - border: 1px solid var(--tblr-border-color, var(--bs-border-color, rgba(0,0,0,.12))); + background: #ffffff; + border: 1px solid rgba(0,0,0,.12); border-radius: 6px; - box-shadow: 0 2px 10px rgba(0,0,0,.12); + box-shadow: 0 2px 10px rgba(0,0,0,.15); padding: 7px 10px; font-size: 11.5px; line-height: 1.4; min-width: 100px; - color: var(--bs-body-color); + color: #212529; +} + +[data-bs-theme="dark"] .legend { + background: #1e2128; + border-color: rgba(255,255,255,.1); + color: #c8d3e1; + box-shadow: 0 2px 12px rgba(0,0,0,.45); +} + +[data-bs-theme="dark"] .legend-section { + color: #6c7a8d; } .legend-section { font-size: 9.5px; @@ -291,6 +302,85 @@ flex-shrink: 0; } +/* ── Full Messages Page ──────────────────────────────────────── */ + +.msg-full-item { + padding: .5rem .875rem; + border-bottom: 1px solid var(--bs-border-color-translucent); +} +.msg-full-item:last-child { border-bottom: none; } + +.msg-full-row { + display: flex; + align-items: flex-start; + gap: .5rem; +} +.msg-full-row-sent { + flex-direction: column; + align-items: flex-end; +} + +.msg-full-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid; + flex-shrink: 0; + margin-top: 2px; +} + +.msg-full-body { + flex: 1; + min-width: 0; +} + +.msg-full-meta { + display: flex; + align-items: center; + gap: .3rem; + flex-wrap: wrap; + margin-bottom: 3px; + font-size: .78rem; +} + +.msg-full-name { + font-size: .78rem; +} + +.msg-ch-badge { + display: inline-block; + padding: 0 .4rem; + border-radius: 3px; + border: 1px solid; + font-size: .68rem; + font-weight: 600; + line-height: 1.5; +} + +.msg-full-bubble { + display: inline-block; + background: rgba(var(--bs-info-rgb), .07); + border: 1px solid rgba(var(--bs-info-rgb), .15); + border-left: 3px solid currentColor; + border-radius: 0 .375rem .375rem 0; + padding: .3rem .55rem; + font-size: .83rem; + max-width: 100%; + word-break: break-word; + color: var(--bs-body-color); +} + +.msg-full-bubble-sent { + background: rgba(var(--bs-success-rgb), .09); + border: 1px solid rgba(var(--bs-success-rgb), .18); + border-right: 3px solid var(--bs-success); + border-radius: .375rem 0 0 .375rem; + text-align: left; +} + /* ── Packet-Log Badges ───────────────────────────────────────── */ .pkt-type-badge { diff --git a/static/index.html b/static/index.html index c9e4e68..b8e5704 100644 --- a/static/index.html +++ b/static/index.html @@ -171,8 +171,10 @@ Name Hardware SNR + RSSI Batterie Hops + Zuletzt diff --git a/static/js/app.js b/static/js/app.js index f82bf9f..597a054 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4,13 +4,14 @@ // ── Sidebar definition ──────────────────────────────────────── const _SIDEBAR_LINKS = [ - { href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false }, - { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true }, - { href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', admin: true }, - { href: '/map', icon: 'bi-map', label: 'Karte', admin: false }, - { href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false }, - { href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true }, - { href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true }, + { href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false, user: false }, + { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true, user: false }, + { href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', admin: true, user: false }, + { href: '/map', icon: 'bi-map', label: 'Karte', admin: false, user: false }, + { href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false, user: false }, + { href: '/messages', icon: 'bi-chat-dots', label: 'Nachrichten', admin: false, user: true }, + { href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true, user: false }, + { href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true, user: false }, ]; function _injectSidebar() { @@ -21,7 +22,8 @@ function _injectSidebar() { _SIDEBAR_LINKS.map(link => { const active = currentPath === link.href ? ' active' : ''; const adm = link.admin ? ' sidebar-admin' : ''; - return `` + + const usr = link.user ? ' sidebar-user' : ''; + return `` + `${link.label}`; }).join('') + ''; @@ -44,10 +46,14 @@ function _updateNavbar(user) { } function _updateSidebar(user) { - const isAdmin = user && user.role === 'admin'; + const isAdmin = user && user.role === 'admin'; + const isUser = !!user; document.querySelectorAll('.sidebar-admin').forEach(el => { el.style.display = isAdmin ? '' : 'none'; }); + document.querySelectorAll('.sidebar-user').forEach(el => { + el.style.display = isUser ? '' : 'none'; + }); } // ── Theme ───────────────────────────────────────────────────── diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 2c22bda..72c2c6e 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -18,6 +18,7 @@ let chartChannel = null; let chartHops = null; let chartHardware = null; let chartPacketTypes = null; +let lastStats = null; initPage({ onAuth: (user) => { currentUser = user; @@ -72,6 +73,7 @@ function connectWebSocket() { channels = msg.data; populateChannelDropdown(); renderMsgFilterBar(); + if (lastStats) updateChannelChart(lastStats); break; case 'my_node_id': myNodeId = msg.data; @@ -126,16 +128,23 @@ function renderNodes() { } const hw = node.hw_model || '-'; const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-'; + const rssi = node.rssi != null ? `${node.rssi}` : '-'; const battery = renderBattery(node.battery); 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' : ''; + const hasPos = node.lat != null && node.lon != null; + const posIcon = hasPos + ? '' + : ''; return ` ${escapeHtml(name)} ${escapeHtml(hw)} ${snr} + ${rssi} ${battery} ${hops} + ${posIcon} ${lastSeen} `; }).join(''); @@ -193,6 +202,7 @@ function updateBotStatus(status) { } function updateStats(stats) { + lastStats = stats; if (stats.version) { document.getElementById('versionLabel').textContent = `v${stats.version}`; } diff --git a/static/js/messages.js b/static/js/messages.js new file mode 100644 index 0000000..bb44c04 --- /dev/null +++ b/static/js/messages.js @@ -0,0 +1,179 @@ +// ── Channel color palette ────────────────────────────────────── +const CH_COLORS = [ + { bg: 'rgba(13,202,240,.12)', border: '#0dcaf0', text: '#0dcaf0' }, // 0 cyan + { bg: 'rgba(25,135,84,.12)', border: '#198754', text: '#198754' }, // 1 green + { bg: 'rgba(255,193,7,.12)', border: '#ffc107', text: '#d9a406' }, // 2 yellow + { bg: 'rgba(220,53,69,.12)', border: '#dc3545', text: '#dc3545' }, // 3 red + { bg: 'rgba(102,16,242,.12)', border: '#6610f2', text: '#6610f2' }, // 4 indigo + { bg: 'rgba(13,110,253,.12)', border: '#0d6efd', text: '#0d6efd' }, // 5 blue + { bg: 'rgba(32,201,151,.12)', border: '#20c997', text: '#20c997' }, // 6 teal + { bg: 'rgba(253,126,20,.12)', border: '#fd7e14', text: '#fd7e14' }, // 7 orange +]; + +function chColor(chIdx) { + return CH_COLORS[chIdx % CH_COLORS.length] || CH_COLORS[0]; +} + +// ── State ────────────────────────────────────────────────────── +let nodes = {}; +let channels = {}; +let myNodeId = null; +let ws; +let msgChannelFilter = 'all'; +let messages = []; + +initPage({ onAuth: (user) => { + if (!user) { + document.getElementById('loginNotice').classList.remove('d-none'); + } else { + document.getElementById('messagesView').classList.remove('d-none'); + connectWebSocket(); + } +}}); + +// ── WebSocket ───────────────────────────────────────────────── +function connectWebSocket() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${proto}//${location.host}/ws`); + + ws.onopen = () => { + document.getElementById('statusDot').classList.add('connected'); + document.getElementById('statusText').textContent = 'Verbunden'; + }; + + ws.onclose = () => { + document.getElementById('statusDot').classList.remove('connected'); + document.getElementById('statusText').textContent = 'Getrennt - Reconnect...'; + setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = () => { ws.close(); }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + switch (msg.type) { + case 'initial': + msg.data.forEach(n => { nodes[n.node_id] = n; }); + break; + case 'node_update': + nodes[msg.data.node_id] = msg.data; + break; + case 'channels': + channels = msg.data; + renderFilterBar(); + break; + case 'my_node_id': + myNodeId = msg.data; + break; + case 'initial_messages': + messages = []; + document.getElementById('messagesList').innerHTML = ''; + msg.data.reverse().forEach(m => addMessage(m)); + break; + case 'new_message': + addMessage(msg.data); + break; + } + }; +} + +// ── Message rendering ───────────────────────────────────────── +function addMessage(msg) { + messages.push(msg); + if (messages.length > 300) messages.shift(); + + const isSent = myNodeId && msg.from_node === myNodeId; + const chIdx = msg.channel != null ? msg.channel : 0; + const chName = channels[chIdx] != null ? channels[chIdx] : `Ch ${chIdx}`; + const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' + }) : ''; + const fromNode = nodes[msg.from_node]; + const fromName = isSent ? 'Bot' : ((fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?')); + const fromId = isSent ? '' : msg.from_node || ''; + const col = chColor(chIdx); + + const item = document.createElement('div'); + item.className = 'msg-full-item' + (isSent ? ' msg-full-sent' : ''); + item.dataset.channel = String(chIdx); + + if (msgChannelFilter !== 'all' && String(chIdx) !== msgChannelFilter) { + item.classList.add('d-none'); + } + + if (isSent) { + // Sent: right-aligned, green + item.innerHTML = ` +
+
+ ${time} + ${escapeHtml(chName)} + ${escapeHtml(fromName)} +
+
${escapeHtml(msg.payload || '')}
+
`; + } else { + // Received: left-aligned, channel colored + item.innerHTML = ` +
+
+ +
+
+
+ ${escapeHtml(fromName)} + ${fromId ? `${escapeHtml(fromId)}` : ''} + ${escapeHtml(chName)} + ${time} +
+
${escapeHtml(msg.payload || '')}
+
+
`; + } + + const list = document.getElementById('messagesList'); + list.prepend(item); + updateCount(); +} + +function updateCount() { + const visible = document.getElementById('messagesList').querySelectorAll('.msg-full-item:not(.d-none)').length; + document.getElementById('msgCount').textContent = visible; +} + +// ── Channel filter bar ──────────────────────────────────────── +function renderFilterBar() { + const bar = document.getElementById('msgFilterBar'); + const sorted = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0])); + const mkBtn = (ch, label, col) => { + const active = ch === msgChannelFilter; + const colStyle = col ? `background:${active ? col.border : 'transparent'};border-color:${col.border};color:${active ? '#fff' : col.text}` : ''; + const cls = col ? '' : (active ? 'btn-secondary' : 'btn-outline-secondary'); + return ``; + }; + bar.innerHTML = + mkBtn('all', 'Alle', null) + + sorted.map(([idx, name]) => mkBtn(String(idx), name, chColor(parseInt(idx)))).join(''); + bar.onclick = (e) => { + const btn = e.target.closest('button[data-ch]'); + if (!btn) return; + msgChannelFilter = btn.dataset.ch; + renderFilterBar(); + applyFilter(); + }; +} + +function applyFilter() { + document.getElementById('messagesList').querySelectorAll('.msg-full-item').forEach(item => { + const vis = msgChannelFilter === 'all' || item.dataset.channel === msgChannelFilter; + item.classList.toggle('d-none', !vis); + }); + updateCount(); +} + +// Clear button +document.getElementById('msgClearBtn').addEventListener('click', () => { + messages = []; + document.getElementById('messagesList').innerHTML = ''; + updateCount(); +}); diff --git a/static/messages.html b/static/messages.html new file mode 100644 index 0000000..1b37fd1 --- /dev/null +++ b/static/messages.html @@ -0,0 +1,83 @@ + + + + + + MeshDD-Bot Nachrichten + + + + + + + + + + + +
+ +
+
+
+ +

Du musst angemeldet sein, um Nachrichten zu sehen.

+ + Anmelden + +
+
+
+ + +
+
+
+ + Nachrichten + 0 + +
+ +
+
+
+
+
+
+ +
+ + + + + +