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 ``;
}).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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+