fix: v0.6.11 - Paket-Log Badge/Filter-Verbesserungen

- Badges gleich breit via CSS min-width:5.5rem
- Typ-Filter-Pills in Typ-Farbe (aktiv gefüllt, inaktiv Outline)
- Undekodierbare Pakete (leerer Portnum) als "?" im Filter
- Kanal-Spalte zeigt Kanalname statt Nummer wenn verfügbar
- Statusdot nutzt classList.add/remove('connected') konsistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-18 17:33:45 +01:00
parent 6187bb4419
commit ed3757199e
4 changed files with 108 additions and 84 deletions

View file

@ -1,5 +1,12 @@
# Changelog # Changelog
## [0.6.11] - 2026-02-18
### Changed
- **Paket-Log**: Badges gleich breit (CSS `min-width:5.5rem`, zentriert), Typ-Filter-Pills in
Typ-Farbe eingefärbt (aktiv: gefüllt, inaktiv: Outline), unbekannte/undekodierbare Pakete
(leerer Portnum) als Typ „?" im Filter sichtbar, Kanal-Spalte zeigt Kanalname wenn verfügbar.
## [0.6.10] - 2026-02-18 ## [0.6.10] - 2026-02-18
### Added ### Added

View file

@ -1,4 +1,4 @@
version: "0.6.10" version: "0.6.11"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

View file

@ -279,6 +279,18 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Packet-Log Badges ───────────────────────────────────────── */
.pkt-type-badge {
display: inline-block;
min-width: 5.5rem;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: .65rem !important;
}
/* ── Scrollbars ──────────────────────────────────────────────── */ /* ── Scrollbars ──────────────────────────────────────────────── */
.table-responsive::-webkit-scrollbar, .table-responsive::-webkit-scrollbar,

View file

@ -1,37 +1,47 @@
// MeshDD-Bot Paket-Log // MeshDD-Bot Paket-Log
const MAX_ROWS = 300; const MAX_ROWS = 300;
const UNKNOWN_TYPE = '__unknown__';
let ws = null; let ws = null;
let nodes = {}; // node_id -> {long_name, short_name} let nodes = {}; // node_id -> {long_name, short_name, ...}
let channels = {}; // ch_index -> channel name string
let paused = false; let paused = false;
let activeFilter = 'all'; let activeFilter = 'all';
let pendingRows = []; // rows held while paused let pendingRows = [];
const pktBody = document.getElementById('pktBody'); const pktBody = document.getElementById('pktBody');
const pktCount = document.getElementById('pktCount'); const pktCount = document.getElementById('pktCount');
const pktFilterBar = document.getElementById('pktFilterBar'); const pktFilterBar = document.getElementById('pktFilterBar');
const pktPauseBtn = document.getElementById('pktPauseBtn'); const pktPauseBtn = document.getElementById('pktPauseBtn');
const pktClearBtn = document.getElementById('pktClearBtn'); const pktClearBtn = document.getElementById('pktClearBtn');
const tableWrapper = document.getElementById('pktTableWrapper');
// ── Portnum config ───────────────────────────────────────── // ── Portnum config ─────────────────────────────────────────────
const PORTNUM_CFG = { const PORTNUM_CFG = {
TEXT_MESSAGE_APP: { label: 'Text', color: 'info' }, TEXT_MESSAGE_APP: { label: 'Text', color: 'info' },
POSITION_APP: { label: 'Position', color: 'success' }, POSITION_APP: { label: 'Position', color: 'success' },
NODEINFO_APP: { label: 'NodeInfo', color: 'primary' }, NODEINFO_APP: { label: 'NodeInfo', color: 'primary' },
TELEMETRY_APP: { label: 'Telemetry', color: 'warning' }, TELEMETRY_APP: { label: 'Telemetry', color: 'warning' },
ROUTING_APP: { label: 'Routing', color: 'secondary'}, ROUTING_APP: { label: 'Routing', color: 'secondary' },
ADMIN_APP: { label: 'Admin', color: 'danger' }, ADMIN_APP: { label: 'Admin', color: 'danger' },
TRACEROUTE_APP: { label: 'Traceroute',color: 'purple' }, TRACEROUTE_APP: { label: 'Traceroute', color: 'purple' },
NEIGHBORINFO_APP: { label: 'Neighbor', color: 'teal' }, NEIGHBORINFO_APP: { label: 'Neighbor', color: 'teal' },
RANGE_TEST_APP: { label: 'RangeTest', color: 'orange' }, RANGE_TEST_APP: { label: 'RangeTest', color: 'orange' },
[UNKNOWN_TYPE]: { label: '?', color: 'secondary' },
}; };
const knownTypes = new Set(); const knownTypes = new Set();
// ── Helpers ──────────────────────────────────────────────── function typeKey(portnum) {
return portnum || UNKNOWN_TYPE;
}
function typeCfg(key) {
return PORTNUM_CFG[key] || { label: key.replace(/_APP$/, ''), color: 'secondary' };
}
// ── Helpers ────────────────────────────────────────────────────
function nodeName(id) { function nodeName(id) {
if (!id) return '—'; if (!id) return '—';
@ -55,46 +65,45 @@ function nodeTitle(id) {
function fmtTime(ts) { function fmtTime(ts) {
if (!ts) return '—'; if (!ts) return '—';
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
const hh = String(d.getHours()).padStart(2, '0'); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
} }
function fmtTo(toId) { function fmtTo(toId) {
if (toId === '4294967295' || toId === '^all' || toId === 'ffffffff') { const broadcast = ['4294967295', '^all', 'ffffffff', '4294967295'];
if (!toId || broadcast.includes(String(toId))) {
return '<span class="text-body-secondary">Alle</span>'; return '<span class="text-body-secondary">Alle</span>';
} }
return `<span ${nodeTitle(toId)}>${nodeName(toId)}</span>`; return `<span ${nodeTitle(toId)}>${nodeName(toId)}</span>`;
} }
function fmtChannel(ch) {
if (ch == null) return '<span class="text-body-secondary">—</span>';
const name = channels[ch];
if (name) return `<span title="Ch ${ch}">${escapeHtml(name)}</span>`;
return `<span>${ch}</span>`;
}
function portnumBadge(portnum) { function portnumBadge(portnum) {
const cfg = PORTNUM_CFG[portnum]; const key = typeKey(portnum);
if (cfg) { const cfg = typeCfg(key);
return `<span class="badge bg-${cfg.color} bg-opacity-20 text-${cfg.color}" style="font-size:.65rem">${cfg.label}</span>`; return `<span class="badge bg-${cfg.color} bg-opacity-20 text-${cfg.color} pkt-type-badge">${escapeHtml(cfg.label)}</span>`;
}
const short = portnum ? portnum.replace(/_APP$/, '') : '?';
return `<span class="badge bg-secondary bg-opacity-20 text-secondary" style="font-size:.65rem">${escapeHtml(short)}</span>`;
} }
function fmtPayload(portnum, payloadStr) { function fmtPayload(portnum, payloadStr) {
let p = {}; let p = {};
try { p = JSON.parse(payloadStr || '{}'); } catch { return ''; } try { p = JSON.parse(payloadStr || '{}'); } catch { return ''; }
if (portnum === 'TEXT_MESSAGE_APP' && p.text) { if (portnum === 'TEXT_MESSAGE_APP' && p.text)
return `<span class="text-body">${escapeHtml(p.text)}</span>`; return `<span class="text-body">${escapeHtml(p.text)}</span>`;
} if (portnum === 'POSITION_APP' && p.lat != null)
if (portnum === 'POSITION_APP' && p.lat != null) {
return `<span class="text-body-secondary">${p.lat?.toFixed(5)}, ${p.lon?.toFixed(5)}</span>`; return `<span class="text-body-secondary">${p.lat?.toFixed(5)}, ${p.lon?.toFixed(5)}</span>`;
}
if (portnum === 'TELEMETRY_APP') { if (portnum === 'TELEMETRY_APP') {
const parts = []; const parts = [];
if (p.battery != null) parts.push(`🔋 ${p.battery}%`); if (p.battery != null) parts.push(`🔋 ${p.battery}%`);
if (p.voltage != null) parts.push(`${p.voltage?.toFixed(2)} V`); if (p.voltage != null) parts.push(`${p.voltage?.toFixed(2)} V`);
return `<span class="text-body-secondary">${parts.join(' · ')}</span>`; return parts.length ? `<span class="text-body-secondary">${parts.join(' · ')}</span>` : '';
} }
if (portnum === 'NODEINFO_APP' && (p.long_name || p.short_name)) { if (portnum === 'NODEINFO_APP' && (p.long_name || p.short_name))
return `<span class="text-body-secondary">${escapeHtml(p.long_name || '')}${p.short_name ? ` [${escapeHtml(p.short_name)}]` : ''}</span>`; return `<span class="text-body-secondary">${escapeHtml(p.long_name || '')}${p.short_name ? ` [${escapeHtml(p.short_name)}]` : ''}</span>`;
}
return ''; return '';
} }
@ -112,19 +121,28 @@ function fmtRssi(v) {
function fmtHops(limit, start) { function fmtHops(limit, start) {
if (start == null || limit == null) return '<span class="text-body-secondary">—</span>'; if (start == null || limit == null) return '<span class="text-body-secondary">—</span>';
const used = start - limit; return `<span class="text-body-secondary">${start - limit}/${start}</span>`;
return `<span class="text-body-secondary">${used}/${start}</span>`;
} }
// ── Filter bar ───────────────────────────────────────────── // ── Filter bar ─────────────────────────────────────────────────
function renderFilterBar() { function renderFilterBar() {
const types = ['all', ...Array.from(knownTypes).sort()]; const types = ['all', ...Array.from(knownTypes).sort((a, b) => {
// UNKNOWN_TYPE always last
if (a === UNKNOWN_TYPE) return 1;
if (b === UNKNOWN_TYPE) return -1;
return a.localeCompare(b);
})];
pktFilterBar.innerHTML = types.map(t => { pktFilterBar.innerHTML = types.map(t => {
const label = t === 'all' ? 'Alle' : (PORTNUM_CFG[t]?.label || t.replace(/_APP$/, ''));
const active = t === activeFilter; const active = t === activeFilter;
return `<button class="btn btn-sm ${active ? 'btn-secondary' : 'btn-outline-secondary'} py-0 px-1 pkt-filter-btn" if (t === 'all') {
data-type="${escapeHtml(t)}" style="font-size:.7rem">${escapeHtml(label)}</button>`; return `<button class="btn btn-sm py-0 px-2 pkt-filter-btn ${active ? 'btn-secondary' : 'btn-outline-secondary'}"
data-type="all" style="font-size:.7rem">Alle</button>`;
}
const cfg = typeCfg(t);
return `<button class="btn btn-sm py-0 px-2 pkt-filter-btn ${active ? 'btn-' + cfg.color : 'btn-outline-' + cfg.color}"
data-type="${escapeHtml(t)}" style="font-size:.7rem">${escapeHtml(cfg.label)}</button>`;
}).join(''); }).join('');
} }
@ -137,26 +155,26 @@ pktFilterBar.addEventListener('click', e => {
}); });
function applyFilter() { function applyFilter() {
pktBody.querySelectorAll('tr[data-portnum]').forEach(row => { pktBody.querySelectorAll('tr[data-type]').forEach(row => {
const visible = activeFilter === 'all' || row.dataset.portnum === activeFilter; const visible = activeFilter === 'all' || row.dataset.type === activeFilter;
row.classList.toggle('d-none', !visible); row.classList.toggle('d-none', !visible);
}); });
} }
// ── Row rendering ────────────────────────────────────────── // ── Row rendering ──────────────────────────────────────────────
function buildRow(pkt) { function buildRow(pkt) {
const key = typeKey(pkt.portnum);
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.dataset.portnum = pkt.portnum || ''; tr.dataset.type = key;
if (activeFilter !== 'all' && tr.dataset.portnum !== activeFilter) { if (activeFilter !== 'all' && key !== activeFilter) tr.classList.add('d-none');
tr.classList.add('d-none');
}
tr.innerHTML = tr.innerHTML =
`<td class="text-body-secondary" style="font-size:.75rem;white-space:nowrap">${fmtTime(pkt.timestamp)}</td>` + `<td class="text-body-secondary" style="font-size:.75rem;white-space:nowrap">${fmtTime(pkt.timestamp)}</td>` +
`<td style="font-size:.8rem;white-space:nowrap"><span ${nodeTitle(pkt.from_id)}>${nodeName(pkt.from_id)}</span></td>` + `<td style="font-size:.8rem;white-space:nowrap"><span ${nodeTitle(pkt.from_id)}>${nodeName(pkt.from_id)}</span></td>` +
`<td style="font-size:.8rem;white-space:nowrap">${fmtTo(pkt.to_id)}</td>` + `<td style="font-size:.8rem;white-space:nowrap">${fmtTo(pkt.to_id)}</td>` +
`<td>${portnumBadge(pkt.portnum)}</td>` + `<td>${portnumBadge(pkt.portnum)}</td>` +
`<td class="text-body-secondary" style="font-size:.8rem">${pkt.channel ?? '—'}</td>` + `<td style="font-size:.8rem;white-space:nowrap">${fmtChannel(pkt.channel)}</td>` +
`<td style="font-size:.8rem">${fmtSnr(pkt.snr)}</td>` + `<td style="font-size:.8rem">${fmtSnr(pkt.snr)}</td>` +
`<td style="font-size:.8rem">${fmtRssi(pkt.rssi)}</td>` + `<td style="font-size:.8rem">${fmtRssi(pkt.rssi)}</td>` +
`<td style="font-size:.8rem">${fmtHops(pkt.hop_limit, pkt.hop_start)}</td>` + `<td style="font-size:.8rem">${fmtHops(pkt.hop_limit, pkt.hop_start)}</td>` +
@ -165,14 +183,11 @@ function buildRow(pkt) {
} }
function addRow(pkt, prepend = true) { function addRow(pkt, prepend = true) {
if (pkt.portnum) knownTypes.add(pkt.portnum); knownTypes.add(typeKey(pkt.portnum));
const row = buildRow(pkt); const row = buildRow(pkt);
if (prepend) { if (prepend) {
pktBody.prepend(row); pktBody.prepend(row);
// Trim excess rows while (pktBody.children.length > MAX_ROWS) pktBody.removeChild(pktBody.lastChild);
while (pktBody.children.length > MAX_ROWS) {
pktBody.removeChild(pktBody.lastChild);
}
} else { } else {
pktBody.appendChild(row); pktBody.appendChild(row);
} }
@ -180,16 +195,14 @@ function addRow(pkt, prepend = true) {
} }
function updateCount() { function updateCount() {
const total = pktBody.children.length; pktCount.textContent = `${pktBody.children.length} Einträge`;
pktCount.textContent = `${total} Einträge`;
} }
// ── Pause / Clear ────────────────────────────────────────── // ── Pause / Clear ──────────────────────────────────────────────
pktPauseBtn.addEventListener('click', () => { pktPauseBtn.addEventListener('click', () => {
paused = !paused; paused = !paused;
const icon = pktPauseBtn.querySelector('i'); pktPauseBtn.querySelector('i').className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
icon.className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
pktPauseBtn.title = paused ? 'Weiter' : 'Pause'; pktPauseBtn.title = paused ? 'Weiter' : 'Pause';
if (!paused && pendingRows.length) { if (!paused && pendingRows.length) {
pendingRows.forEach(p => addRow(p, true)); pendingRows.forEach(p => addRow(p, true));
@ -204,43 +217,38 @@ pktClearBtn.addEventListener('click', () => {
updateCount(); updateCount();
}); });
// ── WebSocket ────────────────────────────────────────────── // ── WebSocket ──────────────────────────────────────────────────
function connectWs() { function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`); ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { ws.onopen = () => {
document.getElementById('statusDot').className = 'status-dot online'; document.getElementById('statusDot').classList.add('connected');
document.getElementById('statusText').textContent = 'Verbunden'; document.getElementById('statusText').textContent = 'Verbunden';
}; };
ws.onclose = () => { ws.onclose = () => {
document.getElementById('statusDot').className = 'status-dot'; document.getElementById('statusDot').classList.remove('connected');
document.getElementById('statusText').textContent = 'Getrennt'; document.getElementById('statusText').textContent = 'Getrennt';
setTimeout(connectWs, 3000); setTimeout(connectWs, 3000);
}; };
ws.onmessage = e => { ws.onmessage = e => handleMsg(JSON.parse(e.data));
const msg = JSON.parse(e.data);
handleMsg(msg);
};
} }
function handleMsg(msg) { function handleMsg(msg) {
switch (msg.type) { switch (msg.type) {
case 'initial': case 'initial':
// Populate nodes map (msg.data || []).forEach(n => { if (n.node_id) nodes[n.node_id] = n; });
(msg.data || []).forEach(n => {
if (n.node_id) nodes[n.node_id] = n;
});
break; break;
case 'node_update': case 'node_update':
if (msg.data && msg.data.node_id) nodes[msg.data.node_id] = msg.data; if (msg.data?.node_id) nodes[msg.data.node_id] = msg.data;
break;
case 'channels':
channels = msg.data || {};
break; break;
case 'initial_packets': case 'initial_packets':
// DB returns newest-first (DESC) — append in that order → newest at top
pktBody.innerHTML = ''; pktBody.innerHTML = '';
(msg.data || []).forEach(p => { (msg.data || []).forEach(p => {
if (p.portnum) knownTypes.add(p.portnum); knownTypes.add(typeKey(p.portnum));
pktBody.appendChild(buildRow(p)); pktBody.appendChild(buildRow(p));
}); });
renderFilterBar(); renderFilterBar();
@ -250,7 +258,6 @@ function handleMsg(msg) {
if (paused) { if (paused) {
pendingRows.push(msg.data); pendingRows.push(msg.data);
} else { } else {
if (msg.data.portnum) knownTypes.add(msg.data.portnum);
addRow(msg.data, true); addRow(msg.data, true);
renderFilterBar(); renderFilterBar();
} }
@ -259,16 +266,14 @@ function handleMsg(msg) {
if (msg.data) { if (msg.data) {
const dot = document.getElementById('statusDot'); const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText'); const text = document.getElementById('statusText');
if (dot && text) { if (dot) dot.classList.toggle('connected', !!msg.data.connected);
dot.className = 'status-dot ' + (msg.data.connected ? 'online' : 'offline'); if (text) text.textContent = msg.data.connected ? 'Verbunden' : 'Getrennt';
text.textContent = msg.data.connected ? 'Verbunden' : 'Getrennt';
}
} }
break; break;
} }
} }
// ── Init ─────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
initPage(); initPage();
connectWs(); connectWs();