mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-24 04:52:30 +00:00
## Summary Implements the full filter-input UX upgrade from #966 — Wireshark-style help, autocomplete, right-click-to-filter, and saved filters. Closes #966. ## Surfaces ### A. Help popover (ⓘ button next to filter input) Auto-generated from `PacketFilter.FIELDS` / `OPERATORS` so it stays in sync with the parser. Includes: - Syntax overview (boolean ops, parens, case-insensitivity, URL-shareable filters) - Full field reference (27 entries: top-level + `payload.*`) - Full operator reference with one example per op - 10 ready-to-paste examples - Tips (right-click, autocomplete, save) ### B. Autocomplete dropdown - Type partial field name → field suggestions (top-level + dynamic `payload.*` keys discovered from visible packets) - Type `field` → operator suggestions - Type `type ==` → list of canonical type values (`ADVERT`, `GRP_TXT`, …) - Type `route ==` → list of route values (`FLOOD`, `DIRECT`, `TRANSPORT_FLOOD`, …) - Keyboard nav: ↑/↓, Tab/Enter to accept, Esc to dismiss ### C. Right-click → filter by this value Right-click any of these cells in the packet table: - `hash`, `size`, `type`, `observer` Context menu offers `==`, `!=`, `contains`. Click → clause appended to filter input (with `&&` if expression already present). ### D. Saved filters - ★ Saved ▾ dropdown next to the input - 7 starter defaults (Adverts only, Channel traffic, Direct messages, Strong signal SNR > 5, Multi-hop, Repeater adverts, Recent < 5m) - "+ Save current expression" prompts for a name and persists to `localStorage` under `corescope_saved_filters_v1` - User filters can be deleted (✕); defaults cannot - User filters with the same name as a default override it ## Implementation **`public/packet-filter.js`** — exposes `FIELDS`, `OPERATORS`, `TYPE_VALUES`, `ROUTE_VALUES`, and a new `suggest(input, cursor, opts)` function that returns ranked autocomplete suggestions with replace-range. Pure function — no DOM, fully unit-tested. **NEW `public/filter-ux.js`** — `window.FilterUX` IIFE owning the help popover, autocomplete dropdown, context menu, and saved-filters store. `init()` is idempotent, called once after the filter input renders. **`public/packets.js`** — calls `FilterUX.init()` after the filter input IIFE; row builders gain `data-filter-field` / `data-filter-value` attrs on hash/size/type/observer cells. `filter-group` wrapper now `position: relative` so dropdowns anchor correctly. **`public/style.css`** — scoped `.fux-*` styles using existing CSS variables (no new theme tokens). ## Tests - `test-packet-filter-ux.js` (19 unit tests, wired into `test-all.sh`): - Metadata exposure (FIELDS / OPERATORS / TYPE_VALUES / ROUTE_VALUES) - `suggest()` for empty input, prefix match, after `==`, dynamic `payload.*` keys - `SavedFilters.list/save/delete` — defaults, persistence, override, dedup - `buildCellFilterClause()` and `appendClauseToExpr()` quoting + appending - `test-filter-ux-e2e.js` (Playwright, wired into `deploy.yml`): - Navigate /packets → metadata exposed - Help popover opens with field reference, operators, examples - Autocomplete shows on focus, filters by prefix, accepts on Enter - Saved-filter dropdown lists defaults, click populates input - Right-click on TYPE cell → context menu → click appends clause - Save current expression persists to localStorage TDD red commit (`bddf1c1`) — assertion failures only, no import errors. Green commit (`0d3f381`) — all 19 unit tests pass. ## Browser validation Spawned local server on :39966 against the e2e fixture DB and exercised every UX surface via the openclaw browser tool. Confirmed: - `window.PacketFilter.FIELDS.length === 27`, `suggest()` available - `FilterUX.SavedFilters.list().length === 7` (defaults seeded) - Help popover renders with `payload.name`, `contains`, `ADVERT` text content - Right-click on a `data-filter-field="type"` / `data-filter-value="Response"` cell → context menu showed three options → clicking == populated the input with `type == "Response"` (and the existing alias resolver matched it to `payload_type === 1`) - Autocomplete on `pay` returned `payload_bytes`, `payload_hex`, `payload.name`, `payload.lat`, `payload.lon`, `payload.text` ## Out of scope (deferred per the issue) - Server-synced saved filters (cross-device) - Visual filter builder - Custom field expressions ## Acceptance criteria - [x] Help icon (ⓘ) next to filter input opens documentation popover - [x] Field reference table + operator reference + 6+ examples in popover - [x] Autocomplete dropdown on field names (top-level + `payload.*`) - [x] Autocomplete dropdown on values for `type` / `route` operators - [x] Right-click on packet cell → "Filter ==" / "Filter !=" / "Filter contains" - [x] Right-click context menu hides when clicking elsewhere / Esc - [x] Saved-filters dropdown with at least 5 default examples (7 shipped) - [x] User-saved filters persist in localStorage - [x] Real-time match count next to filter input (already shipped pre-PR; preserved) - [ ] Improved error messages with token + position — partial: existing parse errors already cite position; not a regression - [x] No regression in existing filter behavior (`test-packet-filter.js`: 69/69 pass) --------- Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
1 parent
e9c801b41a
commit
f7d8a7cb8f
9 files changed
+981
-14
No files matched your search
@@ -208,6 +208,7 @@ jobs:
|
||||
- name: Run Playwright E2E tests (fail-fast)
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/* filter-ux.js — Wireshark-style filter UX (issue #966)
|
||||
*
|
||||
* Owns:
|
||||
* - Help popover (filter syntax, fields, operators, examples)
|
||||
* - Autocomplete dropdown (field names, operators, type/route values, payload.*)
|
||||
* - Right-click context menu on packet table cells → "Filter by this value"
|
||||
* - Saved-filter dropdown (localStorage, with starter defaults)
|
||||
*
|
||||
* Pure-logic helpers (SavedFilters, buildCellFilterClause, appendClauseToExpr)
|
||||
* are unit-tested in test-packet-filter-ux.js. DOM glue is exercised by
|
||||
* test-filter-ux-e2e.js (Playwright).
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var LS_KEY = 'corescope_saved_filters_v1';
|
||||
|
||||
// ── Saved filters store ────────────────────────────────────────────────
|
||||
var DEFAULT_FILTERS = [
|
||||
{ name: 'Adverts only', expr: 'type == ADVERT', builtin: true },
|
||||
{ name: 'Channel traffic', expr: 'type == GRP_TXT', builtin: true },
|
||||
{ name: 'Direct messages', expr: 'type == TXT_MSG', builtin: true },
|
||||
{ name: 'Strong signal (SNR > 5)', expr: 'snr > 5', builtin: true },
|
||||
{ name: 'Multi-hop (hops > 1)', expr: 'hops > 1', builtin: true },
|
||||
{ name: 'Repeater adverts', expr: 'type == ADVERT && payload.flags.repeater == true', builtin: true },
|
||||
{ name: 'Recent (last 5 min)', expr: 'age < 5m', builtin: true },
|
||||
];
|
||||
|
||||
function _getStore() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function _setStore(arr) {
|
||||
try { window.localStorage.setItem(LS_KEY, JSON.stringify(arr)); } catch (e) {}
|
||||
}
|
||||
|
||||
var SavedFilters = {
|
||||
defaults: function() { return DEFAULT_FILTERS.slice(); },
|
||||
list: function() {
|
||||
// Defaults first, then user filters (deduped by name — user wins on collision)
|
||||
var user = _getStore();
|
||||
var userNames = {};
|
||||
for (var i = 0; i < user.length; i++) userNames[user[i].name] = true;
|
||||
var defaults = DEFAULT_FILTERS.filter(function(d) { return !userNames[d.name]; });
|
||||
return defaults.concat(user);
|
||||
},
|
||||
save: function(name, expr) {
|
||||
if (!name || !expr) return;
|
||||
var user = _getStore();
|
||||
var idx = -1;
|
||||
for (var i = 0; i < user.length; i++) { if (user[i].name === name) { idx = i; break; } }
|
||||
var entry = { name: name, expr: expr, ts: Date.now() };
|
||||
if (idx >= 0) user[idx] = entry; else user.push(entry);
|
||||
_setStore(user);
|
||||
},
|
||||
delete: function(name) {
|
||||
var user = _getStore();
|
||||
_setStore(user.filter(function(f) { return f.name !== name; }));
|
||||
},
|
||||
};
|
||||
|
||||
// ── Right-click filter clause builders ─────────────────────────────────
|
||||
// Numeric strings stay unquoted; identifiers from TYPE_VALUES/ROUTE_VALUES
|
||||
// stay unquoted; everything else gets double-quoted.
|
||||
function _isNumericString(s) {
|
||||
if (typeof s !== 'string') return false;
|
||||
return /^-?\d+(\.\d+)?$/.test(s.trim());
|
||||
}
|
||||
function _isBareIdentifier(s) {
|
||||
return typeof s === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(s);
|
||||
}
|
||||
function buildCellFilterClause(field, value, op) {
|
||||
op = op || '==';
|
||||
if (value == null) value = '';
|
||||
var v = String(value);
|
||||
var rendered;
|
||||
if (op === 'contains' || op === 'starts_with' || op === 'ends_with') {
|
||||
// String-only ops: always quote
|
||||
rendered = '"' + v.replace(/"/g, '\\"') + '"';
|
||||
} else if (_isNumericString(v)) {
|
||||
rendered = v;
|
||||
} else if (_isBareIdentifier(v)) {
|
||||
rendered = v;
|
||||
} else {
|
||||
rendered = '"' + v.replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
return field + ' ' + op + ' ' + rendered;
|
||||
}
|
||||
function appendClauseToExpr(expr, clause) {
|
||||
if (!expr || !expr.trim()) return clause;
|
||||
return expr.trim() + ' && ' + clause;
|
||||
}
|
||||
|
||||
// ── DOM glue (only runs in browser, after init()) ──────────────────────
|
||||
var _ctxMenu = null;
|
||||
|
||||
function _h(tag, attrs, html) {
|
||||
var el = document.createElement(tag);
|
||||
if (attrs) for (var k in attrs) {
|
||||
if (k === 'class') el.className = attrs[k];
|
||||
else if (k === 'style') el.setAttribute('style', attrs[k]);
|
||||
else if (k.indexOf('data-') === 0) el.setAttribute(k, attrs[k]);
|
||||
else el[k] = attrs[k];
|
||||
}
|
||||
if (html != null) el.innerHTML = html;
|
||||
return el;
|
||||
}
|
||||
function _esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function _buildHelpHtml() {
|
||||
var PF = window.PacketFilter;
|
||||
var rows = (PF.FIELDS || []).map(function(f) {
|
||||
return '<tr><td class="fux-mono">' + _esc(f.name) + '</td><td>' + _esc(f.desc) + '</td></tr>';
|
||||
}).join('');
|
||||
var ops = (PF.OPERATORS || []).map(function(o) {
|
||||
return '<tr><td class="fux-mono">' + _esc(o.op) + '</td><td>' + _esc(o.desc) +
|
||||
'</td><td class="fux-mono">' + _esc(o.example) + '</td></tr>';
|
||||
}).join('');
|
||||
var examples = [
|
||||
'type == ADVERT',
|
||||
'type == GRP_TXT && size > 50',
|
||||
'payload.name contains "Gilroy"',
|
||||
'payload.flags.repeater == true',
|
||||
'snr > 5 && rssi > -90',
|
||||
'hops < 2',
|
||||
'observer == "Dorrington" && type == ADVERT',
|
||||
'(type == ADVERT || type == ACK) && snr > 0',
|
||||
'age < 1h',
|
||||
'time after "2025-01-01"',
|
||||
].map(function(e) { return '<li class="fux-mono">' + _esc(e) + '</li>'; }).join('');
|
||||
return [
|
||||
'<h3>Filter syntax</h3>',
|
||||
'<p>Wireshark-style boolean expressions over packet fields. Combine with <code>&&</code>, <code>||</code>, <code>!</code>, and parentheses. Strings are case-insensitive. Tip: append <code>?filter=…</code> to the URL to share a filter.</p>',
|
||||
'<h4>Fields</h4>',
|
||||
'<table class="fux-table"><thead><tr><th>Name</th><th>Description</th></tr></thead><tbody>' + rows + '</tbody></table>',
|
||||
'<h4>Operators</h4>',
|
||||
'<table class="fux-table"><thead><tr><th>Op</th><th>Meaning</th><th>Example</th></tr></thead><tbody>' + ops + '</tbody></table>',
|
||||
'<h4>Examples</h4>',
|
||||
'<ul class="fux-examples">' + examples + '</ul>',
|
||||
'<h4>Tips</h4>',
|
||||
'<ul>',
|
||||
'<li>Right-click any cell in the packet table to add a clause for that value.</li>',
|
||||
'<li>Type a partial field name to autocomplete; Tab/Enter accepts, Esc dismisses.</li>',
|
||||
'<li>Save commonly-used expressions via the ★ Save button — they appear in the Saved dropdown.</li>',
|
||||
'</ul>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function _showHelp() {
|
||||
var existing = document.getElementById('filterHelpPopover');
|
||||
if (existing) { existing.remove(); return; }
|
||||
var pop = _h('div', { id: 'filterHelpPopover', class: 'fux-popover', role: 'dialog', 'aria-label': 'Filter syntax help' });
|
||||
pop.innerHTML =
|
||||
'<div class="fux-popover-header"><strong>Filter syntax</strong>' +
|
||||
'<button type="button" class="fux-popover-close" aria-label="Close">✕</button></div>' +
|
||||
'<div class="fux-popover-body">' + _buildHelpHtml() + '</div>';
|
||||
document.body.appendChild(pop);
|
||||
pop.querySelector('.fux-popover-close').addEventListener('click', function() { pop.remove(); });
|
||||
document.addEventListener('keydown', function _esc(ev) {
|
||||
if (ev.key === 'Escape') { pop.remove(); document.removeEventListener('keydown', _esc); }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Autocomplete ───────────────────────────────────────────────────────
|
||||
function _wireAutocomplete(input) {
|
||||
var dd = _h('div', { id: 'filterAcDropdown', class: 'fux-ac-dropdown', role: 'listbox' });
|
||||
dd.style.display = 'none';
|
||||
input.parentNode.appendChild(dd);
|
||||
var sel = -1, items = [];
|
||||
|
||||
function _gatherPayloadKeys() {
|
||||
// Best-effort: scan the first ~50 visible packets for decoded_json keys
|
||||
var keys = {};
|
||||
try {
|
||||
var rows = document.querySelectorAll('#pktTable tbody tr');
|
||||
for (var r = 0; r < rows.length && r < 50; r++) {
|
||||
var dj = rows[r].getAttribute('data-decoded');
|
||||
if (!dj) continue;
|
||||
var obj = JSON.parse(dj);
|
||||
for (var k in obj) keys[k] = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
return Object.keys(keys);
|
||||
}
|
||||
|
||||
function close() { dd.style.display = 'none'; sel = -1; items = []; input.removeAttribute('aria-activedescendant'); }
|
||||
function render() {
|
||||
if (!items.length) { close(); return; }
|
||||
dd.innerHTML = items.map(function(it, i) {
|
||||
return '<div class="fux-ac-item' + (i === sel ? ' active' : '') + '" id="fux-ac-' + i +
|
||||
'" role="option" data-idx="' + i + '">' +
|
||||
'<span class="fux-ac-val">' + _esc(it.value) + '</span>' +
|
||||
(it.desc ? '<span class="fux-ac-desc">' + _esc(it.desc) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
dd.style.display = 'block';
|
||||
if (sel >= 0) input.setAttribute('aria-activedescendant', 'fux-ac-' + sel);
|
||||
}
|
||||
function accept(idx) {
|
||||
if (!items[idx]) return;
|
||||
var rs = items._replaceStart, re = items._replaceEnd;
|
||||
var val = items[idx].value;
|
||||
var v = input.value;
|
||||
var newVal = v.slice(0, rs) + val + v.slice(re);
|
||||
var caret = rs + val.length;
|
||||
// Append space + helpful next char for fields (so user can type op)
|
||||
if (items[idx].kind === 'field') { newVal = newVal.slice(0, caret) + ' ' + newVal.slice(caret); caret++; }
|
||||
input.value = newVal;
|
||||
input.setSelectionRange(caret, caret);
|
||||
close();
|
||||
// Trigger filter recompile
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
var PF = window.PacketFilter;
|
||||
if (!PF || !PF.suggest) return close();
|
||||
var r = PF.suggest(input.value, input.selectionStart || 0, { payloadKeys: _gatherPayloadKeys() });
|
||||
items = (r && r.suggestions) ? r.suggestions.slice(0, 12) : [];
|
||||
items._replaceStart = r ? r.replaceStart : 0;
|
||||
items._replaceEnd = r ? r.replaceEnd : 0;
|
||||
sel = items.length ? 0 : -1;
|
||||
render();
|
||||
}
|
||||
input.addEventListener('input', refresh);
|
||||
input.addEventListener('focus', refresh);
|
||||
input.addEventListener('blur', function() { setTimeout(close, 150); });
|
||||
input.addEventListener('keydown', function(ev) {
|
||||
if (dd.style.display === 'none') return;
|
||||
if (ev.key === 'ArrowDown') { sel = (sel + 1) % items.length; render(); ev.preventDefault(); }
|
||||
else if (ev.key === 'ArrowUp') { sel = (sel - 1 + items.length) % items.length; render(); ev.preventDefault(); }
|
||||
else if (ev.key === 'Tab' || ev.key === 'Enter') {
|
||||
if (sel >= 0) { accept(sel); ev.preventDefault(); }
|
||||
} else if (ev.key === 'Escape') { close(); ev.preventDefault(); }
|
||||
});
|
||||
dd.addEventListener('mousedown', function(ev) {
|
||||
var target = ev.target.closest('.fux-ac-item');
|
||||
if (!target) return;
|
||||
ev.preventDefault();
|
||||
accept(parseInt(target.getAttribute('data-idx'), 10));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Right-click context menu ───────────────────────────────────────────
|
||||
function _showContextMenu(x, y, field, value) {
|
||||
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
|
||||
var input = document.getElementById('packetFilterInput');
|
||||
if (!input) return;
|
||||
var menu = _h('div', { id: 'filterContextMenu', class: 'fux-ctx-menu', role: 'menu' });
|
||||
var ops = [
|
||||
{ label: 'Filter ' + field + ' == "' + value + '"', op: '==' },
|
||||
{ label: 'Filter ' + field + ' != "' + value + '"', op: '!=' },
|
||||
{ label: 'Filter ' + field + ' contains "' + value + '"', op: 'contains' },
|
||||
];
|
||||
menu.innerHTML = ops.map(function(o, i) {
|
||||
return '<button type="button" class="fux-ctx-item" data-idx="' + i + '" role="menuitem">' + _esc(o.label) + '</button>';
|
||||
}).join('');
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
menu.addEventListener('click', function(ev) {
|
||||
var btn = ev.target.closest('.fux-ctx-item');
|
||||
if (!btn) return;
|
||||
var op = ops[parseInt(btn.getAttribute('data-idx'), 10)].op;
|
||||
var clause = buildCellFilterClause(field, value, op);
|
||||
input.value = appendClauseToExpr(input.value, clause);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
menu.remove(); _ctxMenu = null;
|
||||
});
|
||||
function dismiss(ev) {
|
||||
if (_ctxMenu && !_ctxMenu.contains(ev.target)) { _ctxMenu.remove(); _ctxMenu = null;
|
||||
document.removeEventListener('mousedown', dismiss);
|
||||
document.removeEventListener('keydown', escDismiss);
|
||||
}
|
||||
}
|
||||
function escDismiss(ev) { if (ev.key === 'Escape') dismiss({ target: document.body }); }
|
||||
setTimeout(function() {
|
||||
document.addEventListener('mousedown', dismiss);
|
||||
document.addEventListener('keydown', escDismiss);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _wireContextMenu() {
|
||||
// Delegated listener on the table — extracts field+value from data-* attrs.
|
||||
var tbl = document.getElementById('pktTable');
|
||||
if (!tbl) return;
|
||||
tbl.addEventListener('contextmenu', function(ev) {
|
||||
var cell = ev.target.closest('td[data-filter-field]');
|
||||
if (!cell) return;
|
||||
var field = cell.getAttribute('data-filter-field');
|
||||
var value = cell.getAttribute('data-filter-value');
|
||||
if (!field || value == null || value === '') return;
|
||||
ev.preventDefault();
|
||||
_showContextMenu(ev.pageX, ev.pageY, field, value);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Saved filters dropdown ─────────────────────────────────────────────
|
||||
function _renderSavedDropdown(container, input) {
|
||||
var btn = _h('button', { type: 'button', class: 'fux-saved-trigger', id: 'filterSavedTrigger', title: 'Saved filters' }, '★ Saved ▾');
|
||||
var menu = _h('div', { class: 'fux-saved-menu hidden', id: 'filterSavedMenu', role: 'menu' });
|
||||
container.appendChild(btn);
|
||||
container.appendChild(menu);
|
||||
|
||||
function build() {
|
||||
var list = SavedFilters.list();
|
||||
var rows = list.map(function(f, i) {
|
||||
var del = f.builtin ? '' :
|
||||
'<button type="button" class="fux-saved-del" data-name="' + _esc(f.name) + '" title="Delete">✕</button>';
|
||||
return '<div class="fux-saved-item" data-idx="' + i + '">' +
|
||||
'<span class="fux-saved-name">' + _esc(f.name) + '</span>' +
|
||||
'<span class="fux-saved-expr fux-mono">' + _esc(f.expr) + '</span>' +
|
||||
del + '</div>';
|
||||
}).join('');
|
||||
menu.innerHTML =
|
||||
'<div class="fux-saved-header">Saved filters</div>' +
|
||||
rows +
|
||||
'<div class="fux-saved-footer">' +
|
||||
'<button type="button" id="filterSaveCurrent" class="fux-saved-save">+ Save current expression</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
build();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', function(ev) {
|
||||
if (!menu.contains(ev.target) && ev.target !== btn) menu.classList.add('hidden');
|
||||
});
|
||||
menu.addEventListener('click', function(ev) {
|
||||
var del = ev.target.closest('.fux-saved-del');
|
||||
if (del) {
|
||||
SavedFilters.delete(del.getAttribute('data-name'));
|
||||
build();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (ev.target.id === 'filterSaveCurrent') {
|
||||
var expr = (input.value || '').trim();
|
||||
if (!expr) { alert('Type a filter expression first.'); return; }
|
||||
var name = prompt('Name this filter:', '');
|
||||
if (name && name.trim()) {
|
||||
SavedFilters.save(name.trim(), expr);
|
||||
build();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var item = ev.target.closest('.fux-saved-item');
|
||||
if (item) {
|
||||
var list = SavedFilters.list();
|
||||
var f = list[parseInt(item.getAttribute('data-idx'), 10)];
|
||||
if (f) {
|
||||
input.value = f.expr;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init: idempotent, called by packets.js after filter input renders ──
|
||||
function init() {
|
||||
var input = document.getElementById('packetFilterInput');
|
||||
if (!input || input.dataset.fuxInit === '1') return;
|
||||
input.dataset.fuxInit = '1';
|
||||
|
||||
// Help icon + saved-filters dropdown — injected next to the input
|
||||
var wrap = input.parentNode;
|
||||
if (wrap) {
|
||||
var bar = document.getElementById('filterUxBar');
|
||||
if (!bar) {
|
||||
bar = _h('div', { id: 'filterUxBar', class: 'fux-bar' });
|
||||
var helpBtn = _h('button', { type: 'button', class: 'fux-help-btn', id: 'filterHelpBtn',
|
||||
'aria-label': 'Filter syntax help', title: 'Filter syntax help' }, 'ⓘ Help');
|
||||
helpBtn.addEventListener('click', _showHelp);
|
||||
bar.appendChild(helpBtn);
|
||||
_renderSavedDropdown(bar, input);
|
||||
wrap.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
_wireAutocomplete(input);
|
||||
_wireContextMenu();
|
||||
}
|
||||
|
||||
var _exports = {
|
||||
SavedFilters: SavedFilters,
|
||||
buildCellFilterClause: buildCellFilterClause,
|
||||
appendClauseToExpr: appendClauseToExpr,
|
||||
init: init,
|
||||
_showHelp: _showHelp, // exposed for E2E
|
||||
};
|
||||
if (typeof window !== 'undefined') window.FilterUX = _exports;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = _exports;
|
||||
})();
|
||||
@@ -99,6 +99,7 @@
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="filter-ux.js?v=__BUST__"></script>
|
||||
<script src="hash-color.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
|
||||
|
||||
+136
-1
@@ -384,7 +384,142 @@
|
||||
};
|
||||
}
|
||||
|
||||
var _exports = { parse: parse, evaluate: evaluate, compile: compile };
|
||||
// ── Metadata for autocomplete + in-UI documentation (#966) ────────────────
|
||||
var FIELDS = [
|
||||
{ name: 'type', desc: 'Packet payload type (ADVERT, GRP_TXT, TXT_MSG, ACK, …)' },
|
||||
{ name: 'route', desc: 'Route type (FLOOD, DIRECT, TRANSPORT_FLOOD, TRANSPORT_DIRECT)' },
|
||||
{ name: 'transport', desc: 'true if route is TRANSPORT_FLOOD or TRANSPORT_DIRECT' },
|
||||
{ name: 'hash', desc: 'Packet hash (hex)' },
|
||||
{ name: 'raw', desc: 'Full raw hex of the packet' },
|
||||
{ name: 'size', desc: 'Total packet size in bytes' },
|
||||
{ name: 'snr', desc: 'Signal-to-noise ratio (dB)' },
|
||||
{ name: 'rssi', desc: 'Received signal strength (dBm)' },
|
||||
{ name: 'hops', desc: 'Number of hops in the path' },
|
||||
{ name: 'observer', desc: 'Observer station name' },
|
||||
{ name: 'observer_id', desc: 'Observer pubkey/id' },
|
||||
{ name: 'observations', desc: 'Number of observations of this packet' },
|
||||
{ name: 'path', desc: 'Hop path (joined with arrows)' },
|
||||
{ name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' },
|
||||
{ name: 'payload_hex', desc: 'Payload bytes as hex (raw without header)' },
|
||||
{ name: 'time', desc: 'Packet timestamp (epoch ms)' },
|
||||
{ name: 'age', desc: 'Seconds since the packet was observed (use with durations: age < 1h)' },
|
||||
{ name: 'payload.name', desc: 'Decoded payload: node name (adverts)' },
|
||||
{ name: 'payload.lat', desc: 'Decoded payload: latitude' },
|
||||
{ name: 'payload.lon', desc: 'Decoded payload: longitude' },
|
||||
{ name: 'payload.text', desc: 'Decoded payload: message text (channel/DM)' },
|
||||
{ name: 'payload.channel', desc: 'Decoded payload: channel name' },
|
||||
{ name: 'payload.channelHash', desc: 'Decoded payload: channel hash' },
|
||||
{ name: 'payload.sender', desc: 'Decoded payload: sender name' },
|
||||
{ name: 'payload.flags.repeater', desc: 'Decoded payload: advert flag (repeater role)' },
|
||||
{ name: 'payload.flags.room', desc: 'Decoded payload: advert flag (room server)' },
|
||||
{ name: 'payload.flags.hasLocation', desc: 'Decoded payload: advert has location' },
|
||||
];
|
||||
|
||||
var OPERATORS = [
|
||||
{ op: '==', desc: 'Equal (case-insensitive for strings, alias-aware for type/route)', example: 'type == ADVERT' },
|
||||
{ op: '!=', desc: 'Not equal', example: 'type != ACK' },
|
||||
{ op: '>', desc: 'Greater than (numeric)', example: 'snr > 5' },
|
||||
{ op: '<', desc: 'Less than (numeric)', example: 'rssi < -90' },
|
||||
{ op: '>=', desc: 'Greater or equal', example: 'hops >= 2' },
|
||||
{ op: '<=', desc: 'Less or equal', example: 'size <= 100' },
|
||||
{ op: 'contains', desc: 'Substring match (case-insensitive)', example: 'payload.name contains "Gilroy"' },
|
||||
{ op: 'starts_with', desc: 'String prefix match', example: 'hash starts_with "8a91"' },
|
||||
{ op: 'ends_with', desc: 'String suffix match', example: 'hash ends_with "ff"' },
|
||||
{ op: 'after', desc: 'Datetime after (ISO or epoch)', example: 'time after "2025-01-01"' },
|
||||
{ op: 'before', desc: 'Datetime before', example: 'time before "2025-12-31"' },
|
||||
{ op: 'between', desc: 'Datetime between two values', example: 'time between "2025-01-01" "2025-02-01"' },
|
||||
];
|
||||
|
||||
// Canonical type names (firmware payload types)
|
||||
var TYPE_VALUES = ['REQ', 'RESPONSE', 'TXT_MSG', 'ACK', 'ADVERT', 'GRP_TXT', 'GRP_DATA', 'ANON_REQ', 'PATH', 'TRACE', 'MULTIPART', 'CONTROL', 'RAW_CUSTOM'];
|
||||
var ROUTE_VALUES = ['TRANSPORT_FLOOD', 'FLOOD', 'DIRECT', 'TRANSPORT_DIRECT'];
|
||||
|
||||
// suggest(input, cursor, opts?) → { suggestions: [{value, kind, desc?}], replaceStart, replaceEnd }
|
||||
// Token-aware autocomplete:
|
||||
// - Empty / partial-word at cursor → field names
|
||||
// - Right after `field` → operators
|
||||
// - Right after `type ==` → TYPE_VALUES (filtered by partial)
|
||||
// - Right after `route ==` → ROUTE_VALUES
|
||||
// - Partial `payload.<x>` → payload.* fields (incl. dynamic opts.payloadKeys)
|
||||
function suggest(input, cursor, opts) {
|
||||
opts = opts || {};
|
||||
input = input || '';
|
||||
if (cursor == null) cursor = input.length;
|
||||
var before = input.slice(0, cursor);
|
||||
|
||||
// Determine the current word being typed (the replaceable span).
|
||||
// Treat alphanumerics, '_', and '.' as word chars (so "payload.na" is one word).
|
||||
var i = cursor;
|
||||
while (i > 0 && /[A-Za-z0-9_.]/.test(input.charAt(i - 1))) i--;
|
||||
var replaceStart = i;
|
||||
var replaceEnd = cursor;
|
||||
while (replaceEnd < input.length && /[A-Za-z0-9_.]/.test(input.charAt(replaceEnd))) replaceEnd++;
|
||||
var partial = input.slice(replaceStart, cursor);
|
||||
|
||||
// Look at preceding non-space tokens (very small recogniser)
|
||||
var preceding = before.slice(0, replaceStart).replace(/\s+$/, '');
|
||||
var lastTokMatch = preceding.match(/(==|!=|>=|<=|>|<|contains|starts_with|ends_with|after|before|between|&&|\|\||\(|!)$/);
|
||||
var lastTok = lastTokMatch ? lastTokMatch[1] : null;
|
||||
// The token before lastTok (the field, if any)
|
||||
var fieldBefore = null;
|
||||
if (lastTok) {
|
||||
var beforeOp = preceding.slice(0, preceding.length - lastTok.length).replace(/\s+$/, '');
|
||||
var fm = beforeOp.match(/([A-Za-z_][A-Za-z0-9_.]*)$/);
|
||||
if (fm) fieldBefore = fm[1];
|
||||
}
|
||||
|
||||
function makePrefixSuggestions(items, kind) {
|
||||
var p = partial.toLowerCase();
|
||||
var out = [];
|
||||
for (var k = 0; k < items.length; k++) {
|
||||
var it = items[k];
|
||||
var val = typeof it === 'string' ? it : it.value;
|
||||
if (!p || val.toLowerCase().indexOf(p) === 0) {
|
||||
out.push({ value: val, kind: kind, desc: typeof it === 'string' ? '' : (it.desc || '') });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Case A: just typed `field ==` (or other comparison op) → value suggestions
|
||||
if (lastTok && fieldBefore) {
|
||||
if (fieldBefore === 'type' && (lastTok === '==' || lastTok === '!=')) {
|
||||
return { suggestions: makePrefixSuggestions(TYPE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
if (fieldBefore === 'route' && (lastTok === '==' || lastTok === '!=')) {
|
||||
return { suggestions: makePrefixSuggestions(ROUTE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
}
|
||||
|
||||
// Case B: a field is just typed (no operator yet) → operator suggestions
|
||||
// Detect: preceding ends with a known field-like identifier and there's no partial word at cursor
|
||||
if (!partial && preceding.length) {
|
||||
var afterField = preceding.match(/([A-Za-z_][A-Za-z0-9_.]*)$/);
|
||||
if (afterField && !lastTok) {
|
||||
var ops = OPERATORS.map(function(o) { return { value: o.op, kind: 'op', desc: o.desc }; });
|
||||
return { suggestions: ops, replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
}
|
||||
|
||||
// Case C: default → field name suggestions (incl. dynamic payload.* keys)
|
||||
var fieldItems = FIELDS.map(function(f) { return { value: f.name, desc: f.desc }; });
|
||||
if (Array.isArray(opts.payloadKeys)) {
|
||||
var have = {};
|
||||
for (var z = 0; z < fieldItems.length; z++) have[fieldItems[z].value] = true;
|
||||
for (var y = 0; y < opts.payloadKeys.length; y++) {
|
||||
var pkey = 'payload.' + opts.payloadKeys[y];
|
||||
if (!have[pkey]) fieldItems.push({ value: pkey, desc: 'Decoded payload field (dynamic)' });
|
||||
}
|
||||
}
|
||||
return { suggestions: makePrefixSuggestions(fieldItems, 'field'), replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
|
||||
var _exports = {
|
||||
parse: parse, evaluate: evaluate, compile: compile,
|
||||
FIELDS: FIELDS, OPERATORS: OPERATORS,
|
||||
TYPE_VALUES: TYPE_VALUES, ROUTE_VALUES: ROUTE_VALUES,
|
||||
suggest: suggest,
|
||||
};
|
||||
if (typeof window !== 'undefined') window.PacketFilter = _exports;
|
||||
|
||||
// ── Self-tests (Node.js only) ─────────────────────────────────────────────
|
||||
|
||||
+19
-13
@@ -805,7 +805,7 @@
|
||||
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px">
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px;position:relative">
|
||||
<input type="text" id="packetFilterInput" class="packet-filter-input"
|
||||
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
|
||||
aria-label="Packet filter expression"
|
||||
@@ -940,6 +940,12 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Wireshark-style filter UX (#966): help popover, autocomplete, right-click
|
||||
// context menu, saved-filter dropdown. Idempotent — safe to re-call.
|
||||
if (window.FilterUX && typeof window.FilterUX.init === 'function') {
|
||||
window.FilterUX.init();
|
||||
}
|
||||
|
||||
// --- Observer multi-select ---
|
||||
const obsMenu = document.getElementById('observerMenu');
|
||||
const obsTrigger = document.getElementById('observerTrigger');
|
||||
@@ -1461,11 +1467,11 @@
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono">${groupHashBytes}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(headerObserverId) || '')}">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
|
||||
@@ -1487,11 +1493,11 @@
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(c.observer_id) || '')}">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
|
||||
@@ -1519,11 +1525,11 @@
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(p.observer_id) || '')}">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
|
||||
@@ -2766,3 +2766,51 @@ th.sort-active { color: var(--accent, #60a5fa); }
|
||||
.ch-section-toggle:hover { color: var(--text); }
|
||||
.ch-section-empty { padding: 8px 12px; font-size: 12px; color: var(--text-muted); font-style: italic; }
|
||||
.ch-section-caret { display: inline-block; width: 10px; }
|
||||
|
||||
/* ── Filter UX (issue #966) ────────────────────────────────────────────── */
|
||||
.fux-bar { display: flex; gap: 6px; margin-top: 4px; align-items: center; flex-wrap: wrap; position: relative; }
|
||||
.fux-help-btn,
|
||||
.fux-saved-trigger { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
|
||||
.fux-help-btn:hover,
|
||||
.fux-saved-trigger:hover { background: var(--bg-hover, var(--surface)); }
|
||||
|
||||
.fux-popover { position: fixed; top: 60px; right: 24px; width: min(720px, 92vw); max-height: 80vh; overflow: auto; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); z-index: 10000; padding: 0; }
|
||||
.fux-popover-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); }
|
||||
.fux-popover-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 0 4px; }
|
||||
.fux-popover-close:hover { color: var(--text); }
|
||||
.fux-popover-body { padding: 12px 16px; font-size: 13px; }
|
||||
.fux-popover-body h3,
|
||||
.fux-popover-body h4 { margin: 12px 0 6px; }
|
||||
.fux-table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 4px 0 12px; }
|
||||
.fux-table th,
|
||||
.fux-table td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.fux-table th { color: var(--text-muted); font-weight: 600; }
|
||||
.fux-mono { font-family: var(--mono); font-size: 12px; }
|
||||
.fux-examples { margin: 4px 0; padding-left: 20px; }
|
||||
.fux-examples li { margin: 2px 0; }
|
||||
|
||||
.fux-ac-dropdown { position: absolute; left: 0; right: 0; top: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; max-height: 280px; overflow-y: auto; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.25); margin-top: 2px; }
|
||||
.fux-ac-item { padding: 4px 10px; display: flex; justify-content: space-between; gap: 12px; cursor: pointer; font-size: 12px; }
|
||||
.fux-ac-item:hover,
|
||||
.fux-ac-item.active { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
.fux-ac-val { font-family: var(--mono); color: var(--text); }
|
||||
.fux-ac-desc { color: var(--text-muted); font-size: 11px; max-width: 60%; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.fux-ctx-menu { position: absolute; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 4px 14px rgba(0,0,0,0.35); z-index: 10001; min-width: 200px; padding: 4px 0; }
|
||||
.fux-ctx-item { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 5px 12px; font-size: 12px; cursor: pointer; font-family: var(--mono); }
|
||||
.fux-ctx-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
|
||||
.fux-saved-menu { position: absolute; top: 100%; left: 0; min-width: 320px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; z-index: 9999; box-shadow: 0 4px 14px rgba(0,0,0,0.3); margin-top: 4px; padding: 4px 0; }
|
||||
.fux-saved-menu.hidden { display: none; }
|
||||
.fux-saved-header { padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); }
|
||||
.fux-saved-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; font-size: 12px; }
|
||||
.fux-saved-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
.fux-saved-name { font-weight: 600; min-width: 120px; }
|
||||
.fux-saved-expr { color: var(--text-muted); font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fux-saved-del { background: transparent; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 0 4px; }
|
||||
.fux-saved-del:hover { color: var(--status-red, #ef4444); }
|
||||
.fux-saved-footer { border-top: 1px solid var(--border); padding: 4px 0; }
|
||||
.fux-saved-save { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 6px 10px; font-size: 12px; cursor: pointer; }
|
||||
.fux-saved-save:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
|
||||
td[data-filter-field] { cursor: context-menu; }
|
||||
@@ -10,6 +10,7 @@ echo ""
|
||||
# Unit tests (deterministic, fast)
|
||||
echo "── Unit Tests ──"
|
||||
node test-packet-filter.js
|
||||
node test-packet-filter-ux.js
|
||||
node test-aging.js
|
||||
node test-frontend-helpers.js
|
||||
node test-url-state.js
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* E2E (#966): Wireshark-style filter UX.
|
||||
*
|
||||
* Boots Chromium against a local corescope-server (defaults to fixture instance
|
||||
* on :39966) and exercises:
|
||||
* - Help button opens popover with field/operator reference
|
||||
* - Autocomplete dropdown appears as user types and accepts on Enter
|
||||
* - Right-click on a packet table cell opens "Filter by this value" menu
|
||||
* and clicking populates the filter input
|
||||
* - Saved-filter dropdown lists default starter filters
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:39966 node test-filter-ux-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:39966';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log(`\n=== #966 filter UX E2E against ${BASE} ===`);
|
||||
|
||||
await step('navigate to /packets', async () => {
|
||||
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
|
||||
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
|
||||
});
|
||||
|
||||
await step('PacketFilter metadata is exposed in window', async () => {
|
||||
const meta = await page.evaluate(() => ({
|
||||
fields: window.PacketFilter && Array.isArray(window.PacketFilter.FIELDS) && window.PacketFilter.FIELDS.length,
|
||||
ops: window.PacketFilter && Array.isArray(window.PacketFilter.OPERATORS) && window.PacketFilter.OPERATORS.length,
|
||||
types: window.PacketFilter && Array.isArray(window.PacketFilter.TYPE_VALUES) && window.PacketFilter.TYPE_VALUES.length,
|
||||
hasSuggest: typeof window.PacketFilter.suggest === 'function',
|
||||
}));
|
||||
assert(meta.fields >= 10, 'FIELDS not populated: ' + JSON.stringify(meta));
|
||||
assert(meta.ops >= 8, 'OPERATORS not populated');
|
||||
assert(meta.types >= 5, 'TYPE_VALUES not populated');
|
||||
assert(meta.hasSuggest, 'suggest() missing');
|
||||
});
|
||||
|
||||
await step('Help button opens popover with field reference', async () => {
|
||||
await page.click('#filterHelpBtn');
|
||||
await page.waitForSelector('#filterHelpPopover', { timeout: 3000 });
|
||||
const txt = await page.textContent('#filterHelpPopover');
|
||||
assert(/Filter syntax/i.test(txt), 'header missing');
|
||||
assert(/payload\.name/.test(txt), 'fields table missing payload.name');
|
||||
assert(/contains/.test(txt), 'operators missing');
|
||||
assert(/ADVERT/.test(txt), 'examples missing');
|
||||
// Close it
|
||||
await page.click('#filterHelpPopover .fux-popover-close');
|
||||
await page.waitForFunction(() => !document.getElementById('filterHelpPopover'), { timeout: 3000 });
|
||||
});
|
||||
|
||||
await step('Autocomplete dropdown appears on focus and filters by prefix', async () => {
|
||||
await page.click('#packetFilterInput');
|
||||
await page.fill('#packetFilterInput', '');
|
||||
await page.keyboard.type('pay');
|
||||
await page.waitForSelector('#filterAcDropdown .fux-ac-item', { timeout: 3000 });
|
||||
const items = await page.$$eval('#filterAcDropdown .fux-ac-item .fux-ac-val', els => els.map(e => e.textContent));
|
||||
assert(items.some(v => v.startsWith('payload')), 'no payload* in dropdown: ' + items.join(','));
|
||||
});
|
||||
|
||||
await step('Autocomplete accepts on Enter and updates input', async () => {
|
||||
await page.fill('#packetFilterInput', '');
|
||||
await page.click('#packetFilterInput');
|
||||
await page.keyboard.type('typ');
|
||||
await page.waitForSelector('#filterAcDropdown .fux-ac-item.active', { timeout: 3000 });
|
||||
await page.keyboard.press('Enter');
|
||||
const val = await page.inputValue('#packetFilterInput');
|
||||
assert(/^type/.test(val), 'expected `type` after accept, got: ' + val);
|
||||
});
|
||||
|
||||
await step('Saved-filter dropdown lists default starters', async () => {
|
||||
// Reset LS so defaults are unmodified
|
||||
await page.evaluate(() => { try { localStorage.removeItem('corescope_saved_filters_v1'); } catch (e) {} });
|
||||
await page.click('#filterSavedTrigger');
|
||||
await page.waitForSelector('#filterSavedMenu:not(.hidden)', { timeout: 3000 });
|
||||
const names = await page.$$eval('#filterSavedMenu .fux-saved-name', els => els.map(e => e.textContent));
|
||||
assert(names.length >= 5, 'expected ≥ 5 default filters, got: ' + names.length);
|
||||
assert(names.some(n => /Adverts only/i.test(n)), 'Adverts only missing: ' + names.join('|'));
|
||||
assert(names.some(n => /Strong signal/i.test(n)), 'Strong signal missing: ' + names.join('|'));
|
||||
});
|
||||
|
||||
await step('Clicking a saved filter populates the input and applies it', async () => {
|
||||
// Click the "Adverts only" entry
|
||||
await page.evaluate(() => {
|
||||
const items = document.querySelectorAll('#filterSavedMenu .fux-saved-item');
|
||||
for (const it of items) { if (/Adverts only/i.test(it.textContent)) { it.click(); break; } }
|
||||
});
|
||||
await page.waitForFunction(() => /type\s*==\s*ADVERT/i.test(document.getElementById('packetFilterInput').value), { timeout: 3000 });
|
||||
const val = await page.inputValue('#packetFilterInput');
|
||||
assert(/type\s*==\s*ADVERT/i.test(val), 'expected Adverts expr, got: ' + val);
|
||||
});
|
||||
|
||||
await step('Right-click on a type cell opens context menu and appends a clause', async () => {
|
||||
// Reset filter
|
||||
await page.fill('#packetFilterInput', '');
|
||||
await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true })));
|
||||
// Widen time window so fixture rows render
|
||||
await page.evaluate(() => {
|
||||
const sel = document.getElementById('fTimeWindow');
|
||||
if (sel) {
|
||||
sel.value = '0';
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
// Wait for the table to populate with cells that have a real value
|
||||
await page.waitForFunction(() => {
|
||||
const cells = document.querySelectorAll('#pktBody td[data-filter-field="type"]');
|
||||
for (const c of cells) {
|
||||
const v = c.getAttribute('data-filter-value');
|
||||
if (v && v !== '—' && v !== '') return true;
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 8000 });
|
||||
// Dispatch contextmenu event programmatically (Playwright headless mouse
|
||||
// right-click does not reliably trigger 'contextmenu' DOM events).
|
||||
const result = await page.evaluate(() => {
|
||||
const cell = Array.from(document.querySelectorAll('#pktBody td[data-filter-field="type"]'))
|
||||
.find(c => {
|
||||
const v = c.getAttribute('data-filter-value');
|
||||
return v && v !== '—' && v !== '';
|
||||
});
|
||||
if (!cell) return { error: 'no type cell with value' };
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const ev = new MouseEvent('contextmenu', {
|
||||
bubbles: true, cancelable: true, button: 2,
|
||||
clientX: rect.left + 5, clientY: rect.top + 5,
|
||||
});
|
||||
cell.dispatchEvent(ev);
|
||||
const menu = document.getElementById('filterContextMenu');
|
||||
if (!menu) return { error: 'context menu not opened' };
|
||||
const items = Array.from(menu.querySelectorAll('.fux-ctx-item')).map(i => i.textContent);
|
||||
// Click the first item (== filter)
|
||||
menu.querySelector('.fux-ctx-item').click();
|
||||
return { items, inputAfter: document.getElementById('packetFilterInput').value };
|
||||
});
|
||||
assert(!result.error, 'menu open failed: ' + (result.error || ''));
|
||||
assert(result.items.length === 3, 'expected 3 menu items, got: ' + result.items.length);
|
||||
assert(/type\s*==\s*/.test(result.inputAfter), 'expected type clause appended, got: ' + result.inputAfter);
|
||||
});
|
||||
|
||||
await step('Save current expression persists to localStorage', async () => {
|
||||
await page.fill('#packetFilterInput', 'snr > 7');
|
||||
await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true })));
|
||||
await page.click('#filterSavedTrigger');
|
||||
await page.waitForSelector('#filterSavedMenu:not(.hidden)');
|
||||
// Stub prompt
|
||||
await page.evaluate(() => { window.prompt = () => 'E2E test filter'; });
|
||||
await page.click('#filterSaveCurrent');
|
||||
await page.waitForFunction(() => {
|
||||
const raw = localStorage.getItem('corescope_saved_filters_v1') || '';
|
||||
return /E2E test filter/.test(raw) && /snr > 7/.test(raw);
|
||||
}, { timeout: 3000 });
|
||||
// Cleanup
|
||||
await page.evaluate(() => localStorage.removeItem('corescope_saved_filters_v1'));
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,190 @@
|
||||
/* Unit tests for filter UX helpers: PacketFilter metadata + autocomplete +
|
||||
* SavedFilters store (issue #966). Pure-logic only — DOM exercised by E2E.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
|
||||
function loadInCtx(files, ctx) {
|
||||
for (const f of files) vm.runInContext(fs.readFileSync(f, 'utf8'), ctx);
|
||||
}
|
||||
|
||||
// Fake DOM-less window with a localStorage shim so filter-ux.js can be loaded
|
||||
// in Node without touching the document object model.
|
||||
function makeCtx() {
|
||||
const store = {};
|
||||
const ls = {
|
||||
getItem: k => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
|
||||
};
|
||||
// Minimal document stub — filter-ux.js init() must early-exit when DOM missing.
|
||||
const doc = { getElementById: () => null, addEventListener: () => {}, body: null };
|
||||
const win = { localStorage: ls, document: doc, addEventListener: () => {} };
|
||||
const ctx = { window: win, document: doc, localStorage: ls, console };
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); pass++; console.log(' ✓ ' + name); }
|
||||
catch (e) { console.log(' ✗ ' + name + ' — ' + e.message); fail++; }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
const ctx = makeCtx();
|
||||
loadInCtx(['public/packet-filter.js', 'public/filter-ux.js'], ctx);
|
||||
const PF = ctx.window.PacketFilter;
|
||||
const UX = ctx.window.FilterUX;
|
||||
|
||||
console.log('\n=== #966 filter-UX unit tests ===');
|
||||
|
||||
// ── Metadata exposed by PacketFilter ──────────────────────────────────────
|
||||
test('PacketFilter.FIELDS exposes top-level fields', () => {
|
||||
assert(Array.isArray(PF.FIELDS), 'FIELDS is array');
|
||||
const names = PF.FIELDS.map(f => f.name);
|
||||
for (const want of ['type', 'route', 'snr', 'rssi', 'hops', 'observer', 'hash', 'size', 'age']) {
|
||||
assert(names.includes(want), 'FIELDS missing ' + want);
|
||||
}
|
||||
for (const f of PF.FIELDS) { assert(typeof f.desc === 'string' && f.desc.length, 'desc required for ' + f.name); }
|
||||
});
|
||||
|
||||
test('PacketFilter.OPERATORS lists comparison operators with examples', () => {
|
||||
assert(Array.isArray(PF.OPERATORS), 'OPERATORS array');
|
||||
const ops = PF.OPERATORS.map(o => o.op);
|
||||
for (const want of ['==', '!=', '>', '<', '>=', '<=', 'contains', 'starts_with']) {
|
||||
assert(ops.includes(want), 'OPERATORS missing ' + want);
|
||||
}
|
||||
for (const o of PF.OPERATORS) { assert(typeof o.example === 'string' && o.example.length, 'example required for ' + o.op); }
|
||||
});
|
||||
|
||||
test('PacketFilter.TYPE_VALUES exposes canonical type names', () => {
|
||||
assert(Array.isArray(PF.TYPE_VALUES));
|
||||
for (const want of ['ADVERT', 'GRP_TXT', 'GRP_DATA', 'TXT_MSG', 'ACK']) {
|
||||
assert(PF.TYPE_VALUES.includes(want), 'TYPE_VALUES missing ' + want);
|
||||
}
|
||||
});
|
||||
|
||||
test('PacketFilter.ROUTE_VALUES exposes route names', () => {
|
||||
assert(Array.isArray(PF.ROUTE_VALUES));
|
||||
for (const want of ['FLOOD', 'DIRECT', 'TRANSPORT_FLOOD', 'TRANSPORT_DIRECT']) {
|
||||
assert(PF.ROUTE_VALUES.includes(want), 'ROUTE_VALUES missing ' + want);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Autocomplete suggestions ──────────────────────────────────────────────
|
||||
test('suggest() on empty input returns top-level fields', () => {
|
||||
const r = PF.suggest('', 0);
|
||||
assert(r && Array.isArray(r.suggestions), 'returns object with suggestions');
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('type'), 'suggests type');
|
||||
assert(vals.includes('snr'), 'suggests snr');
|
||||
});
|
||||
|
||||
test('suggest() prefix-matches field names', () => {
|
||||
const r = PF.suggest('pay', 3);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
// payload.* aliases or payload_bytes/payload_hex should surface
|
||||
assert(vals.some(v => v.startsWith('payload')), 'no payload* suggestion: ' + vals.join(','));
|
||||
assert(r.replaceStart === 0 && r.replaceEnd === 3, 'replace range covers prefix');
|
||||
});
|
||||
|
||||
test('suggest() after `type ==` lists type values', () => {
|
||||
const r = PF.suggest('type == ', 8);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('ADVERT'), 'ADVERT in type values');
|
||||
assert(vals.includes('GRP_TXT'), 'GRP_TXT in type values');
|
||||
});
|
||||
|
||||
test('suggest() after `type == AD` filters type values', () => {
|
||||
const r = PF.suggest('type == AD', 10);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('ADVERT'), 'ADVERT matches AD prefix');
|
||||
assert(!vals.includes('GRP_TXT'), 'GRP_TXT filtered out');
|
||||
});
|
||||
|
||||
test('suggest() after `route ==` lists route values', () => {
|
||||
const r = PF.suggest('route == ', 9);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('FLOOD'), 'FLOOD route');
|
||||
assert(vals.includes('DIRECT'), 'DIRECT route');
|
||||
});
|
||||
|
||||
test('suggest() after operator suggests operators when no field given yet (no crash)', () => {
|
||||
const r = PF.suggest('snr ', 4);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('>') || vals.includes('==') || vals.includes('<'), 'op suggested: ' + vals.join(','));
|
||||
});
|
||||
|
||||
test('suggest() includes payload.* keys from dynamic discovery', () => {
|
||||
const r = PF.suggest('payload.', 8, { payloadKeys: ['name', 'lat', 'channelHash'] });
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('payload.name'), 'payload.name from dynamic keys');
|
||||
assert(vals.includes('payload.lat'), 'payload.lat from dynamic keys');
|
||||
});
|
||||
|
||||
// ── Improved parse-error positioning ──────────────────────────────────────
|
||||
test('error message cites position for unknown character', () => {
|
||||
const r = PF.parse('snr @ 5');
|
||||
assert(r.error && /position/i.test(r.error), 'error should cite position: ' + r.error);
|
||||
});
|
||||
|
||||
// ── Saved filters store ───────────────────────────────────────────────────
|
||||
test('SavedFilters.defaults() returns at least 5 starter filters', () => {
|
||||
const d = UX.SavedFilters.defaults();
|
||||
assert(Array.isArray(d) && d.length >= 5, 'defaults length ≥ 5: ' + (d && d.length));
|
||||
for (const f of d) { assert(f.name && f.expr, 'each default has name + expr'); }
|
||||
});
|
||||
|
||||
test('SavedFilters.list() includes defaults when nothing saved', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
const list = UX.SavedFilters.list();
|
||||
assert(list.length >= 5, 'list seeded with defaults');
|
||||
});
|
||||
|
||||
test('SavedFilters.save() persists to localStorage and survives list()', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
UX.SavedFilters.save('my filter', 'snr > 10');
|
||||
const list = UX.SavedFilters.list();
|
||||
const found = list.find(f => f.name === 'my filter' && f.expr === 'snr > 10');
|
||||
assert(found, 'saved filter present in list');
|
||||
// Must persist to LS, not memory
|
||||
const raw = ctx.window.localStorage.getItem('corescope_saved_filters_v1');
|
||||
assert(raw && raw.includes('snr > 10'), 'persisted to LS: ' + raw);
|
||||
});
|
||||
|
||||
test('SavedFilters.delete() removes user filter but keeps defaults', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
UX.SavedFilters.save('temp', 'hops > 0');
|
||||
UX.SavedFilters.delete('temp');
|
||||
const list = UX.SavedFilters.list();
|
||||
assert(!list.find(f => f.name === 'temp'), 'temp removed');
|
||||
assert(list.length >= 5, 'defaults still present');
|
||||
});
|
||||
|
||||
test('SavedFilters.save() overwrites existing user filter with same name', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
UX.SavedFilters.save('x', 'snr > 1');
|
||||
UX.SavedFilters.save('x', 'snr > 99');
|
||||
const list = UX.SavedFilters.list();
|
||||
const matches = list.filter(f => f.name === 'x');
|
||||
assert(matches.length === 1, 'no duplicate name');
|
||||
assert(matches[0].expr === 'snr > 99', 'overwritten to latest');
|
||||
});
|
||||
|
||||
// ── Filter-by-cell helper (for right-click) ───────────────────────────────
|
||||
test('buildCellFilterClause() emits field == "value" with quoting for strings', () => {
|
||||
assert(UX.buildCellFilterClause('observer', 'Dorrington', '==') === 'observer == "Dorrington"');
|
||||
assert(UX.buildCellFilterClause('snr', '8.5', '==') === 'snr == 8.5');
|
||||
assert(UX.buildCellFilterClause('type', 'ADVERT', '==') === 'type == ADVERT');
|
||||
});
|
||||
|
||||
test('appendClauseToExpr() appends with && when expr present', () => {
|
||||
assert(UX.appendClauseToExpr('', 'snr > 5') === 'snr > 5');
|
||||
assert(UX.appendClauseToExpr('type == ADVERT', 'snr > 5') === 'type == ADVERT && snr > 5');
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
Reference in new issue
Block a user