MeshDD-Bot/static/js/packets.js
ppfeiffer f608f513a8 feat(packets): Erweiterte Filterzeile + Freitextsuche (closes #6)
- Filterzeile mit Von, An, Kanal-Dropdown, Hops (≤) und Freitextsuche
- buildRow() befüllt data-from/to/channel/hops/search für performante Filterung
- rowVisible() prüft alle aktiven Filter (AND-Logik)
- Channel-Dropdown wird beim channels-WS-Event befüllt
- Reset-Button setzt alle Zusatzfilter zurück

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:41:55 +01:00

424 lines
16 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 myNodeId = null;
let paused = false;
let activeFilter = 'all';
let pendingRows = [];
let filterFrom = '';
let filterTo = '';
let filterChannel = '';
let filterHops = '';
let searchText = '';
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>`;
}
// Colors that need dark text on their background
const _LIGHT_BG = new Set(['warning', 'yellow', 'lime']);
function portnumBadge(portnum) {
const key = typeKey(portnum);
const cfg = typeCfg(key);
const txtCls = _LIGHT_BG.has(cfg.color) ? 'text-dark' : 'text-white';
return `<span class="badge bg-${cfg.color} pkt-type-badge ${txtCls}">${escapeHtml(cfg.label)}</span>`;
}
function isSuppressed(pkt) {
if (pkt.portnum !== 'TELEMETRY_APP') return false;
if (myNodeId && pkt.from_id === myNodeId) return true;
const n = nodes[pkt.from_id];
return !!(n && n.short_name === 'FTLW');
}
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) {
const parts = [`${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}`];
if (p.alt != null && p.alt !== 0) parts.push(`${p.alt} m`);
if (p.speed != null && p.speed !== 0) parts.push(`${p.speed} km/h`);
if (p.sats != null) parts.push(`${p.sats} Sat`);
return `<span class="text-body-secondary">${parts.join(' · ')}</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`);
if (p.ch_util != null) parts.push(`CH ${p.ch_util.toFixed(1)}%`);
if (p.air_util != null) parts.push(`TX ${p.air_util.toFixed(1)}%`);
if (p.temp != null) parts.push(`🌡 ${p.temp.toFixed(1)} °C`);
if (p.humidity != null) parts.push(`💧 ${p.humidity.toFixed(0)}%`);
if (p.pressure != null) parts.push(`${p.pressure.toFixed(0)} hPa`);
return parts.length ? `<span class="text-body-secondary">${parts.join(' · ')}</span>` : '';
}
if (portnum === 'NODEINFO_APP' && (p.long_name || p.short_name)) {
let s = escapeHtml(p.long_name || '');
if (p.short_name) s += ` [${escapeHtml(p.short_name)}]`;
if (p.hw_model) s += ` <span class="opacity-50">${escapeHtml(p.hw_model)}</span>`;
return `<span class="text-body-secondary">${s}</span>`;
}
if (portnum === 'ROUTING_APP' && p.error && p.error !== 'NONE')
return `<span class="text-danger">${escapeHtml(p.error)}</span>`;
if (portnum === 'TRACEROUTE_APP' && p.hops != null)
return `<span class="text-body-secondary">${p.hops} Hop${p.hops !== 1 ? 's' : ''}</span>`;
if (portnum === 'NEIGHBORINFO_APP' && p.count != null)
return `<span class="text-body-secondary">${p.count} Nachbar${p.count !== 1 ? 'n' : ''}</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 rowVisible(row) {
const typeOk = activeFilter === 'all' || row.dataset.type === activeFilter;
const fromOk = !filterFrom || (row.dataset.from || '').includes(filterFrom);
const toOk = !filterTo || (row.dataset.to || '').includes(filterTo);
const chOk = !filterChannel || row.dataset.channel === filterChannel;
const hopsOk = filterHops === '' || (
row.dataset.hops !== '' &&
parseInt(row.dataset.hops, 10) <= parseInt(filterHops, 10)
);
const searchOk = !searchText || (row.dataset.search || '').includes(searchText);
return typeOk && fromOk && toOk && chOk && hopsOk && searchOk;
}
function applyFilter() {
pktBody.querySelectorAll('tr[data-type]').forEach(row => {
row.classList.toggle('d-none', !rowVisible(row));
});
const hasFilter = filterFrom || filterTo || filterChannel || filterHops !== '' || searchText;
document.getElementById('fClearBtn').classList.toggle('d-none', !hasFilter);
}
function fillChannelSelect() {
const sel = document.getElementById('fChSelect');
if (!sel) return;
const prev = sel.value;
while (sel.options.length > 1) sel.remove(1);
Object.entries(channels).sort(([a], [b]) => Number(a) - Number(b)).forEach(([idx, name]) => {
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = name ? `${idx}: ${name}` : String(idx);
sel.appendChild(opt);
});
sel.value = prev;
}
// ── Row rendering ──────────────────────────────────────────────
function buildSearchData(pkt, key) {
const fromName = `${pkt.from_id || ''} ${nodes[pkt.from_id]?.long_name || ''} ${nodes[pkt.from_id]?.short_name || ''}`.toLowerCase().trim();
const isBcast = !pkt.to_id || ['4294967295', 'ffffffff'].includes(String(pkt.to_id));
const toName = isBcast
? 'alle broadcast'
: `${pkt.to_id} ${nodes[pkt.to_id]?.long_name || ''} ${nodes[pkt.to_id]?.short_name || ''}`.toLowerCase().trim();
const chName = `${pkt.channel ?? ''} ${channels[pkt.channel] || channels[Number(pkt.channel)] || ''}`.toLowerCase().trim();
let payloadTxt = '';
try {
const p = JSON.parse(pkt.payload || '{}');
payloadTxt = Object.values(p).filter(v => typeof v === 'string' || typeof v === 'number').join(' ');
} catch {}
return {
from: fromName,
to: toName,
search: `${fromName} ${toName} ${typeCfg(key).label.toLowerCase()} ${chName} ${payloadTxt}`.toLowerCase(),
};
}
function buildRow(pkt) {
const key = typeKey(pkt.portnum);
const sd = buildSearchData(pkt, key);
const tr = document.createElement('tr');
tr.dataset.type = key;
tr.dataset.from = sd.from;
tr.dataset.to = sd.to;
tr.dataset.channel = String(pkt.channel ?? '');
tr.dataset.hops = (pkt.hop_start != null && pkt.hop_limit != null)
? String(pkt.hop_start - pkt.hop_limit) : '';
tr.dataset.search = sd.search;
if (!rowVisible(tr)) 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) {
if (isSuppressed(pkt)) return;
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 || {};
fillChannelSelect();
break;
case 'my_node_id':
myNodeId = msg.data;
break;
case 'initial_packets':
pktBody.innerHTML = '';
(msg.data || []).forEach(p => {
if (isSuppressed(p)) return;
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;
}
}
// ── Erweiterte Filter-Listener ──────────────────────────────────
document.getElementById('fVon').addEventListener('input', e => {
filterFrom = e.target.value.trim().toLowerCase();
applyFilter();
});
document.getElementById('fAn').addEventListener('input', e => {
filterTo = e.target.value.trim().toLowerCase();
applyFilter();
});
document.getElementById('fChSelect').addEventListener('change', e => {
filterChannel = e.target.value;
applyFilter();
});
document.getElementById('fHops').addEventListener('input', e => {
filterHops = e.target.value.trim();
applyFilter();
});
document.getElementById('fText').addEventListener('input', e => {
searchText = e.target.value.trim().toLowerCase();
applyFilter();
});
document.getElementById('fClearBtn').addEventListener('click', () => {
filterFrom = filterTo = filterChannel = searchText = '';
filterHops = '';
document.getElementById('fVon').value = '';
document.getElementById('fAn').value = '';
document.getElementById('fChSelect').value = '';
document.getElementById('fHops').value = '';
document.getElementById('fText').value = '';
applyFilter();
});
// ── Init ───────────────────────────────────────────────────────
initPage();
connectWs();