MeshDD-Bot/static/js/packets.js
ppfeiffer ed3757199e 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>
2026-02-18 17:33:45 +01:00

280 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// MeshDD-Bot Paket-Log
const MAX_ROWS = 300;
const UNKNOWN_TYPE = '__unknown__';
let ws = null;
let nodes = {}; // node_id -> {long_name, short_name, ...}
let channels = {}; // ch_index -> channel name string
let paused = false;
let activeFilter = 'all';
let pendingRows = [];
const pktBody = document.getElementById('pktBody');
const pktCount = document.getElementById('pktCount');
const pktFilterBar = document.getElementById('pktFilterBar');
const pktPauseBtn = document.getElementById('pktPauseBtn');
const pktClearBtn = document.getElementById('pktClearBtn');
// ── Portnum config ─────────────────────────────────────────────
const PORTNUM_CFG = {
TEXT_MESSAGE_APP: { label: 'Text', color: 'info' },
POSITION_APP: { label: 'Position', color: 'success' },
NODEINFO_APP: { label: 'NodeInfo', color: 'primary' },
TELEMETRY_APP: { label: 'Telemetry', color: 'warning' },
ROUTING_APP: { label: 'Routing', color: 'secondary' },
ADMIN_APP: { label: 'Admin', color: 'danger' },
TRACEROUTE_APP: { label: 'Traceroute', color: 'purple' },
NEIGHBORINFO_APP: { label: 'Neighbor', color: 'teal' },
RANGE_TEST_APP: { label: 'RangeTest', color: 'orange' },
[UNKNOWN_TYPE]: { label: '?', color: 'secondary' },
};
const knownTypes = new Set();
function typeKey(portnum) {
return portnum || UNKNOWN_TYPE;
}
function typeCfg(key) {
return PORTNUM_CFG[key] || { label: key.replace(/_APP$/, ''), color: 'secondary' };
}
// ── Helpers ────────────────────────────────────────────────────
function nodeName(id) {
if (!id) return '—';
const n = nodes[id];
if (n) {
const name = n.short_name || n.long_name;
if (name) return escapeHtml(name);
}
return escapeHtml(id);
}
function nodeTitle(id) {
if (!id) return '';
const n = nodes[id];
if (n && (n.long_name || n.short_name)) {
return `title="${escapeHtml(n.long_name || n.short_name)} (${escapeHtml(id)})"`;
}
return '';
}
function fmtTime(ts) {
if (!ts) return '—';
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
}
function fmtTo(toId) {
const broadcast = ['4294967295', '^all', 'ffffffff', '4294967295'];
if (!toId || broadcast.includes(String(toId))) {
return '<span class="text-body-secondary">Alle</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) {
const key = typeKey(portnum);
const cfg = typeCfg(key);
return `<span class="badge bg-${cfg.color} bg-opacity-20 text-${cfg.color} pkt-type-badge">${escapeHtml(cfg.label)}</span>`;
}
function fmtPayload(portnum, payloadStr) {
let p = {};
try { p = JSON.parse(payloadStr || '{}'); } catch { return ''; }
if (portnum === 'TEXT_MESSAGE_APP' && p.text)
return `<span class="text-body">${escapeHtml(p.text)}</span>`;
if (portnum === 'POSITION_APP' && p.lat != null)
return `<span class="text-body-secondary">${p.lat?.toFixed(5)}, ${p.lon?.toFixed(5)}</span>`;
if (portnum === 'TELEMETRY_APP') {
const parts = [];
if (p.battery != null) parts.push(`🔋 ${p.battery}%`);
if (p.voltage != null) parts.push(`${p.voltage?.toFixed(2)} V`);
return parts.length ? `<span class="text-body-secondary">${parts.join(' · ')}</span>` : '';
}
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 '';
}
function fmtSnr(v) {
if (v == null) return '<span class="text-body-secondary">—</span>';
const cls = v >= 5 ? 'text-success' : v >= 0 ? 'text-warning' : 'text-danger';
return `<span class="${cls}">${v > 0 ? '+' : ''}${v}</span>`;
}
function fmtRssi(v) {
if (v == null) return '<span class="text-body-secondary">—</span>';
const cls = v >= -100 ? 'text-success' : v >= -115 ? 'text-warning' : 'text-danger';
return `<span class="${cls}">${v}</span>`;
}
function fmtHops(limit, start) {
if (start == null || limit == null) return '<span class="text-body-secondary">—</span>';
return `<span class="text-body-secondary">${start - limit}/${start}</span>`;
}
// ── Filter bar ─────────────────────────────────────────────────
function renderFilterBar() {
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 => {
const active = t === activeFilter;
if (t === 'all') {
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('');
}
pktFilterBar.addEventListener('click', e => {
const btn = e.target.closest('.pkt-filter-btn');
if (!btn) return;
activeFilter = btn.dataset.type;
renderFilterBar();
applyFilter();
});
function applyFilter() {
pktBody.querySelectorAll('tr[data-type]').forEach(row => {
const visible = activeFilter === 'all' || row.dataset.type === activeFilter;
row.classList.toggle('d-none', !visible);
});
}
// ── Row rendering ──────────────────────────────────────────────
function buildRow(pkt) {
const key = typeKey(pkt.portnum);
const tr = document.createElement('tr');
tr.dataset.type = key;
if (activeFilter !== 'all' && key !== activeFilter) tr.classList.add('d-none');
tr.innerHTML =
`<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">${fmtTo(pkt.to_id)}</td>` +
`<td>${portnumBadge(pkt.portnum)}</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">${fmtRssi(pkt.rssi)}</td>` +
`<td style="font-size:.8rem">${fmtHops(pkt.hop_limit, pkt.hop_start)}</td>` +
`<td style="font-size:.8rem">${fmtPayload(pkt.portnum, pkt.payload)}</td>`;
return tr;
}
function addRow(pkt, prepend = true) {
knownTypes.add(typeKey(pkt.portnum));
const row = buildRow(pkt);
if (prepend) {
pktBody.prepend(row);
while (pktBody.children.length > MAX_ROWS) pktBody.removeChild(pktBody.lastChild);
} else {
pktBody.appendChild(row);
}
updateCount();
}
function updateCount() {
pktCount.textContent = `${pktBody.children.length} Einträge`;
}
// ── Pause / Clear ──────────────────────────────────────────────
pktPauseBtn.addEventListener('click', () => {
paused = !paused;
pktPauseBtn.querySelector('i').className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
pktPauseBtn.title = paused ? 'Weiter' : 'Pause';
if (!paused && pendingRows.length) {
pendingRows.forEach(p => addRow(p, true));
pendingRows = [];
renderFilterBar();
}
});
pktClearBtn.addEventListener('click', () => {
pktBody.innerHTML = '';
pendingRows = [];
updateCount();
});
// ── WebSocket ──────────────────────────────────────────────────
function connectWs() {
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';
setTimeout(connectWs, 3000);
};
ws.onmessage = e => handleMsg(JSON.parse(e.data));
}
function handleMsg(msg) {
switch (msg.type) {
case 'initial':
(msg.data || []).forEach(n => { if (n.node_id) nodes[n.node_id] = n; });
break;
case 'node_update':
if (msg.data?.node_id) nodes[msg.data.node_id] = msg.data;
break;
case 'channels':
channels = msg.data || {};
break;
case 'initial_packets':
pktBody.innerHTML = '';
(msg.data || []).forEach(p => {
knownTypes.add(typeKey(p.portnum));
pktBody.appendChild(buildRow(p));
});
renderFilterBar();
updateCount();
break;
case 'packet':
if (paused) {
pendingRows.push(msg.data);
} else {
addRow(msg.data, true);
renderFilterBar();
}
break;
case 'bot_status':
if (msg.data) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (dot) dot.classList.toggle('connected', !!msg.data.connected);
if (text) text.textContent = msg.data.connected ? 'Verbunden' : 'Getrennt';
}
break;
}
}
// ── Init ───────────────────────────────────────────────────────
initPage();
connectWs();