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>
This commit is contained in:
ppfeiffer 2026-02-20 22:41:55 +01:00
parent 511ff20842
commit f608f513a8
4 changed files with 130 additions and 8 deletions

View file

@ -1,5 +1,14 @@
# Changelog
## [0.08.25] - 2026-02-20
### Added
- **Paket-Log: Erweiterte Filterzeile** (closes #6): Unterhalb des Typ-Filters
neue Zeile mit Von, An, Kanal-Dropdown, Hops-Maximalwert und Freitextsuche.
Alle Filter wirken kombiniert (AND-Logik). Reset-Button (✕) setzt alle
Zusatzfilter zurück. `buildRow()` befüllt `data-from/to/channel/hops/search`
für performante DOM-Filterung ohne Re-Render.
## [0.08.24] - 2026-02-20
### Added

View file

@ -1,4 +1,4 @@
version: "0.08.24"
version: "0.08.25"
bot:
name: "MeshDD-Bot"

View file

@ -11,6 +11,12 @@ 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');
@ -194,20 +200,74 @@ pktFilterBar.addEventListener('click', e => {
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 => {
const visible = activeFilter === 'all' || row.dataset.type === activeFilter;
row.classList.toggle('d-none', !visible);
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 tr = document.createElement('tr');
tr.dataset.type = key;
if (activeFilter !== 'all' && key !== activeFilter) tr.classList.add('d-none');
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>` +
@ -285,6 +345,7 @@ function handleMsg(msg) {
break;
case 'channels':
channels = msg.data || {};
fillChannelSelect();
break;
case 'my_node_id':
myNodeId = msg.data;
@ -318,6 +379,44 @@ function handleMsg(msg) {
}
}
// ── 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();

View file

@ -55,8 +55,22 @@
<i class="bi bi-trash" style="font-size:.75rem"></i>
</button>
</div>
<!-- Erweiterte Filterzeile -->
<div class="border-bottom px-2 py-1 d-flex gap-1 flex-wrap align-items-center" id="pktAdvFilters">
<input type="text" class="form-control form-control-sm" id="fVon" placeholder="Von…" style="width:110px;font-size:.73rem">
<input type="text" class="form-control form-control-sm" id="fAn" placeholder="An…" style="width:110px;font-size:.73rem">
<select class="form-select form-select-sm" id="fChSelect" style="width:120px;font-size:.73rem">
<option value="">Kanal: Alle</option>
</select>
<input type="number" class="form-control form-control-sm" id="fHops" placeholder="Hops ≤" style="width:75px;font-size:.73rem" min="0" max="9">
<input type="search" class="form-control form-control-sm flex-grow-1" id="fText" placeholder="🔍 Freitext…" style="min-width:120px;font-size:.73rem">
<button class="btn btn-sm btn-outline-secondary py-0 px-2 d-none" id="fClearBtn" title="Filter zurücksetzen">
<i class="bi bi-x-lg" style="font-size:.7rem"></i>
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height:calc(100vh - 130px);overflow-y:auto" id="pktTableWrapper">
<div class="table-responsive" style="max-height:calc(100vh - 168px);overflow-y:auto" id="pktTableWrapper">
<table class="table table-sm table-hover mb-0" id="pktTable">
<thead class="sticky-top">
<tr>