- 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>
424 lines
16 KiB
JavaScript
424 lines
16 KiB
JavaScript
// 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();
|