MeshDD-Bot/static/js/packets.js
ppfeiffer 6187bb4419 feat: v0.6.10 - Paket-Log-Seite und größeres Node-Modal
- 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>
2026-02-18 17:17:14 +01:00

275 lines
10 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;
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();