- Neue öffentliche Seite /packets: Echtzeit-Tabelle aller empfangenen Meshtastic-Pakete via WebSocket mit Typ-Filter, Pause und Clear - DB: packets-Tabelle + insert_packet / get_recent_packets - bot.py: alle Pakete loggen + WS-Broadcast (public) - webserver.py: /packets Route + /api/packets + initial_packets im WS - Sidebar: Eintrag 'Pakete' (öffentlich) - Node-Modal: modal-xl + scrollable, Kartenhöhe 250→300px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
// MeshDD-Bot – Paket-Log
|
||
|
||
const MAX_ROWS = 300;
|
||
|
||
let ws = null;
|
||
let nodes = {}; // node_id -> {long_name, short_name}
|
||
let paused = false;
|
||
let activeFilter = 'all';
|
||
let pendingRows = []; // rows held while paused
|
||
|
||
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');
|
||
const tableWrapper = document.getElementById('pktTableWrapper');
|
||
|
||
// ── 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' },
|
||
};
|
||
|
||
const knownTypes = new Set();
|
||
|
||
// ── 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);
|
||
const hh = String(d.getHours()).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) {
|
||
if (toId === '4294967295' || toId === '^all' || toId === 'ffffffff') {
|
||
return '<span class="text-body-secondary">Alle</span>';
|
||
}
|
||
return `<span ${nodeTitle(toId)}>${nodeName(toId)}</span>`;
|
||
}
|
||
|
||
function portnumBadge(portnum) {
|
||
const cfg = PORTNUM_CFG[portnum];
|
||
if (cfg) {
|
||
return `<span class="badge bg-${cfg.color} bg-opacity-20 text-${cfg.color}" style="font-size:.65rem">${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) {
|
||
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 `<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>';
|
||
const used = start - limit;
|
||
return `<span class="text-body-secondary">${used}/${start}</span>`;
|
||
}
|
||
|
||
// ── Filter bar ─────────────────────────────────────────────
|
||
|
||
function renderFilterBar() {
|
||
const types = ['all', ...Array.from(knownTypes).sort()];
|
||
pktFilterBar.innerHTML = types.map(t => {
|
||
const label = t === 'all' ? 'Alle' : (PORTNUM_CFG[t]?.label || t.replace(/_APP$/, ''));
|
||
const active = t === activeFilter;
|
||
return `<button class="btn btn-sm ${active ? 'btn-secondary' : 'btn-outline-secondary'} py-0 px-1 pkt-filter-btn"
|
||
data-type="${escapeHtml(t)}" style="font-size:.7rem">${escapeHtml(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-portnum]').forEach(row => {
|
||
const visible = activeFilter === 'all' || row.dataset.portnum === activeFilter;
|
||
row.classList.toggle('d-none', !visible);
|
||
});
|
||
}
|
||
|
||
// ── Row rendering ──────────────────────────────────────────
|
||
|
||
function buildRow(pkt) {
|
||
const tr = document.createElement('tr');
|
||
tr.dataset.portnum = pkt.portnum || '';
|
||
if (activeFilter !== 'all' && tr.dataset.portnum !== 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 class="text-body-secondary" style="font-size:.8rem">${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) {
|
||
if (pkt.portnum) knownTypes.add(pkt.portnum);
|
||
const row = buildRow(pkt);
|
||
if (prepend) {
|
||
pktBody.prepend(row);
|
||
// Trim excess rows
|
||
while (pktBody.children.length > MAX_ROWS) {
|
||
pktBody.removeChild(pktBody.lastChild);
|
||
}
|
||
} else {
|
||
pktBody.appendChild(row);
|
||
}
|
||
updateCount();
|
||
}
|
||
|
||
function updateCount() {
|
||
const total = pktBody.children.length;
|
||
pktCount.textContent = `${total} Einträge`;
|
||
}
|
||
|
||
// ── Pause / Clear ──────────────────────────────────────────
|
||
|
||
pktPauseBtn.addEventListener('click', () => {
|
||
paused = !paused;
|
||
const icon = pktPauseBtn.querySelector('i');
|
||
icon.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').className = 'status-dot online';
|
||
document.getElementById('statusText').textContent = 'Verbunden';
|
||
};
|
||
ws.onclose = () => {
|
||
document.getElementById('statusDot').className = 'status-dot';
|
||
document.getElementById('statusText').textContent = 'Getrennt';
|
||
setTimeout(connectWs, 3000);
|
||
};
|
||
ws.onmessage = e => {
|
||
const msg = JSON.parse(e.data);
|
||
handleMsg(msg);
|
||
};
|
||
}
|
||
|
||
function handleMsg(msg) {
|
||
switch (msg.type) {
|
||
case 'initial':
|
||
// Populate nodes map
|
||
(msg.data || []).forEach(n => {
|
||
if (n.node_id) nodes[n.node_id] = n;
|
||
});
|
||
break;
|
||
case 'node_update':
|
||
if (msg.data && msg.data.node_id) nodes[msg.data.node_id] = msg.data;
|
||
break;
|
||
case 'initial_packets':
|
||
// DB returns newest-first (DESC) — append in that order → newest at top
|
||
pktBody.innerHTML = '';
|
||
(msg.data || []).forEach(p => {
|
||
if (p.portnum) knownTypes.add(p.portnum);
|
||
pktBody.appendChild(buildRow(p));
|
||
});
|
||
renderFilterBar();
|
||
updateCount();
|
||
break;
|
||
case 'packet':
|
||
if (paused) {
|
||
pendingRows.push(msg.data);
|
||
} else {
|
||
if (msg.data.portnum) knownTypes.add(msg.data.portnum);
|
||
addRow(msg.data, true);
|
||
renderFilterBar();
|
||
}
|
||
break;
|
||
case 'bot_status':
|
||
if (msg.data) {
|
||
const dot = document.getElementById('statusDot');
|
||
const text = document.getElementById('statusText');
|
||
if (dot && text) {
|
||
dot.className = 'status-dot ' + (msg.data.connected ? 'online' : 'offline');
|
||
text.textContent = msg.data.connected ? 'Verbunden' : 'Getrennt';
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Init ───────────────────────────────────────────────────
|
||
|
||
initPage();
|
||
connectWs();
|