diff --git a/README.md b/README.md index b44a670..9c9cb45 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,12 @@ GSMTAP into this analyser. and AUTH commands get distinctive styling in map view - Sessions with deep links (`#session=N`), PCAP import (`.pcap/.pcapng/.cap`), COMPRION trace import (the Device Manager *ISO Protocol Layer* text export), - search and type filtering + search and a **multi-select command filter** — pick any number of commands + and/or the conversation presets (*Proactive sessions*: FETCH, TERMINAL + PROFILE and every command answered with SW 91xx; *Proactive + events*: the + same plus ENVELOPE; *File operations*: SELECT, READ/UPDATE BINARY and + READ/UPDATE RECORD, with the GET RESPONSE carrying a selected command's + data) - Installable PWA, dark theme, English/Russian UI ## Prerequisites diff --git a/README_RUS.md b/README_RUS.md index 06c9f54..8b80907 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -73,7 +73,12 @@ SIM/UICC из UDP-потока GSMTAP (или импортирует файлы TERMINAL RESPONSE, ENVELOPE и AUTH-команды выделяются в режиме карты - Сессии с прямыми ссылками (`#session=N`), импорт PCAP (`.pcap/.pcapng/.cap`), импорт трасс COMPRION (текстовый экспорт - «ISO Protocol Layer» из Device Manager), поиск и фильтрация по типу + «ISO Protocol Layer» из Device Manager), поиск и **фильтр команд с + множественным выбором** — любые команды и/или готовые пресеты («Проактивные + сессии»: FETCH, TERMINAL PROFILE и все команды с ответом SW 91xx; + «Проактивные + события»: то же плюс ENVELOPE; «Файловые операции»: SELECT, + READ/UPDATE BINARY и READ/UPDATE RECORD, вместе с GET RESPONSE, несущим + данные выбранной команды) - Устанавливаемая PWA, тёмная тема, интерфейс на английском/русском ## Требования diff --git a/frontend/index.html b/frontend/index.html index 56ffbbc..87080db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -121,6 +121,24 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(- .trace-topbar button.connect { background: var(--warn); color: #0f172a; } .trace-topbar button.connect:hover { background: var(--warn-hover); } .trace-topbar button.disabled { background: var(--bg-input); color: var(--text-muted); cursor: not-allowed; opacity: 0.6; } + +/* Command filter: a button + a checkbox dropdown (presets and commands) */ +.filter-dropdown { position: relative; } +#filter-btn { background: var(--bg-input); color: var(--text); border: 1px solid var(--border); + border-radius: 4px; padding: 8px 12px; cursor: pointer; font-weight: 400; + max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#filter-btn:hover { background: var(--border); } +.filter-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 60; min-width: 260px; + max-height: 60vh; overflow-y: auto; padding: 6px; background: var(--bg-panel); + border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px var(--modal-bg); } +.filter-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 4px; + cursor: pointer; font-size: 13px; white-space: nowrap; } +.filter-item:hover { background: var(--bg-input); } +.filter-item input { margin: 0; cursor: pointer; } +.filter-item .filter-count { margin-left: auto; color: var(--text-muted); font-size: 12px; } +.filter-menu .filter-heading { margin: 6px 8px 2px; color: var(--text-muted); font-size: 11px; + text-transform: uppercase; letter-spacing: 1px; } +.filter-sep { height: 1px; margin: 6px 4px; background: var(--border); } .trace-main { display: flex; flex: 1; overflow: hidden; } .trace-left { flex: 1; display: flex; flex-direction: column; overflow: hidden; } @@ -352,10 +370,11 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
- - + Filter +
+ + +
${escapeHtml(label)}` + + `${count === undefined ? '' : `${count}`}`; + menu.innerHTML = [ + item('all', t('All commands'), !STATE.filter.size), + `
${t('Presets')}
`, + ...FILTER_PRESETS.map(p => item(p.id, t(p.name), STATE.filter.has(p.id))), + '
', + `
${t('Commands')}
`, + ...keys.map(k => item('cmd:' + k, k, STATE.filter.has('cmd:' + k), + counts[k])), + ].join(''); + el('filter-btn').textContent = filterSummary(); +} + +function toggleFilterMenu(force) { + const menu = el('filter-menu'); + if (!menu) return; + const open = force === undefined ? menu.style.display === 'none' : !!force; + if (open) renderFilterMenu(true); + menu.style.display = open ? '' : 'none'; + el('filter-btn').setAttribute('aria-expanded', open ? 'true' : 'false'); +} + +function setFilterEntry(entry, on) { + if (entry === 'all') STATE.filter.clear(); + else if (on) STATE.filter.add(entry); + else STATE.filter.delete(entry); + renderFilterMenu(true); + renderMessages(); +} + +function clearFilter() { + if (!STATE.filter.size) return; + STATE.filter.clear(); + renderFilterMenu(true); } function scrollToType(typeKey) { @@ -2356,10 +2463,9 @@ function scrollToType(typeKey) { } function focusMessage(idx) { - const filterEl = el('filter-select'); const searchEl = el('search-input'); - const needRender = filterEl.value !== '' || searchEl.value !== ''; - filterEl.value = ''; + const needRender = STATE.filter.size > 0 || searchEl.value !== ''; + clearFilter(); searchEl.value = ''; // The focused entry stays selected until another one is focused; the // state lets a re-render (the live poll included) keep it marked. @@ -2382,13 +2488,29 @@ function isOriginalCommand(i) { return false; } -function matchesFilter(m, i, filterType) { - if (!filterType) return true; - if (filter_label(m) === filterType) return true; +function matchesFilter(m, i, filterSet) { + if (!filterSet || !filterSet.size) return true; + const label = filter_label(m); const d = m.decoded; const isGR = m.type === 'tpdu' && d && d.ins_name === 'GET RESPONSE'; - if (isGR && d.response_for === filterType) return true; - if (filterType === 'GET RESPONSE' && isOriginalCommand(i)) return true; + const origin = isGR ? d.response_for : null; + for (const entry of filterSet) { + const preset = filterPreset(entry); + if (preset) { + // A preset covers the commands it names and the rows its rule + // matches (SW 91xx / the file-operation INS set); the GET + // RESPONSE that carries a selected command's data comes along. + if (preset.test(m) || preset.labels.includes(label) || + (origin && preset.labels.includes(origin))) return true; + continue; + } + const want = entry.slice(4); + if (want === label) return true; + // A GET RESPONSE belongs to the command that fetched it; selecting + // GET RESPONSE also shows those commands. + if (origin && want === origin) return true; + if (want === 'GET RESPONSE' && isOriginalCommand(i)) return true; + } return false; } @@ -2429,12 +2551,12 @@ function renderMessages(skipScroll, forceStick) { // scrollHeight, which would hide the fact that the reader was at the end. const stick = shouldStickToBottom(list, skipScroll, forceStick); const searchQ = el('search-input').value.toLowerCase(); - const filterType = el('filter-select').value; + const filterSet = STATE.filter; const filtered = []; for (let i = 0; i < STATE.messages.length; i++) { const m = STATE.messages[i]; - if (!matchesFilter(m, i, filterType)) continue; + if (!matchesFilter(m, i, filterSet)) continue; if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue; filtered.push({ m, idx: i }); } @@ -2442,7 +2564,7 @@ function renderMessages(skipScroll, forceStick) { buildTypeCounts(); buildTraceMap(); renderSidePanel(); - populateFilterOptions(); + renderFilterMenu(); if (!STATE.captureActive && !STATE.sessionViewId) { renderTraceEmptyState(); @@ -3221,12 +3343,8 @@ function onSearchChange() { _searchTimer = setTimeout(renderMessages, 200); } -function onFilterChange() { - renderMessages(); -} - // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.6.5'; +const PWA_VERSION = '2.7.0'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { @@ -3261,6 +3379,12 @@ const LANG_RU = { 'Install PWA': 'Установить PWA', 'Search hex bytes...': 'Поиск по данным (hex)...', 'All commands': 'Все команды', + 'Presets': 'Пресеты', + 'Commands': 'Команды', + 'Proactive sessions': 'Проактивные сессии', + 'Proactive + events': 'Проактивные + события', + 'File operations': 'Файловые операции', + '{n} commands': 'команд: {n}', 'Filter': 'Фильтр', 'Start Capture': 'Начать захват', 'Stop Capture': 'Остановить захват', @@ -3586,7 +3710,17 @@ function init() { $on('view-list-btn', 'click', () => toggleSessionView('list')); $on('search-input', 'input', onSearchChange); - $on('filter-select', 'change', onFilterChange); + $on('filter-btn', 'click', () => toggleFilterMenu()); + $on('filter-menu', 'change', e => { + const box = e.target.closest('input[data-filter]'); + if (box) setFilterEntry(box.dataset.filter, box.checked); + }); + document.addEventListener('click', e => { + if (!e.target.closest('#filter-dropdown')) toggleFilterMenu(false); + }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape') toggleFilterMenu(false); + }); $on('server-source', 'change', () => { if (el('server-source').value === 'same') { diff --git a/frontend/sw.js b/frontend/sw.js index 6d927c2..32e0e8c 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v79'; +const CACHE = 'simtrace-analyser-v80'; const PRECACHE = [ 'index.html', diff --git a/frontend/tests/files_jump.test.js b/frontend/tests/files_jump.test.js index c94d9c4..f0e4d2c 100644 --- a/frontend/tests/files_jump.test.js +++ b/frontend/tests/files_jump.test.js @@ -15,6 +15,7 @@ let renders = []; let focused = []; let el = () => ({}); let showDetail = () => {}; +function renderFilterMenu() {} // the real one needs the menu DOM function renderMessages(skipScroll, forceStick) { renders.push([skipScroll, forceStick]); } @@ -27,7 +28,8 @@ eval( ); // The real focusMessage, under an alias: the stub above stays in place for // the jumpToMessage tests. -eval('var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';'); +eval(extractFunc(src, 'clearFilter') + '\n' + + 'var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';'); test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => { renders = []; @@ -97,7 +99,8 @@ test('focusMessage marks the record persistently and updates the detail', () => classList: { add: c => classes.add(c), remove: c => classes.delete(c) }, scrollIntoView: () => {}, }; - STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0 }; + STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0, + filter: new Set() }; el = () => ({ value: '', querySelector: () => entry }); let detailed = null; showDetail = (msg, e) => { detailed = [msg, e]; }; @@ -116,12 +119,15 @@ test('focusMessage clears a filter before focusing', () => { classList: { add: () => {}, remove: () => {} }, scrollIntoView: () => {}, }; - STATE = { messages: {}, selectedIdx: null }; + STATE = { messages: {}, selectedIdx: null, + filter: new Set(['cmd:SELECT']) }; el = () => ({ value: 'x', querySelector: () => entry }); showDetail = () => {}; realFocusMessage(3); assert.deepEqual(renders, [[true, undefined]]); assert.equal(STATE.selectedIdx, 3); + // The stale filter is gone, so the focused record is visible. + assert.equal(STATE.filter.size, 0); }); test('the selection persists across renders (source pins)', () => { diff --git a/frontend/tests/filter.test.js b/frontend/tests/filter.test.js new file mode 100644 index 0000000..237c327 --- /dev/null +++ b/frontend/tests/filter.test.js @@ -0,0 +1,174 @@ +// The trace view's command filter: a set of entries — single command labels +// and conversation presets — with an empty set meaning "all commands". + +const { test } = require('node:test'); +const assert = require('node:assert'); +const { loadSrc, extractConst, extractFunc } = require('./helpers.js'); + +const src = loadSrc(); +let STATE = {}; +const presetsSrc = src.match(/const FILTER_PRESETS = \[[\s\S]*?\n\];/); +assert.ok(presetsSrc, 'FILTER_PRESETS not found'); +eval( + presetsSrc[0].replace(/^const /m, 'var ') + '\n' + + extractFunc(src, 'filterPreset') + '\n' + + extractFunc(src, '_sw1') + '\n' + + extractFunc(src, 'filter_label') + '\n' + + extractFunc(src, '_ins_hex') + '\n' + + extractFunc(src, 'matchesFilter') + '\n' + + extractFunc(src, 'isOriginalCommand') + '\n' + + extractFunc(src, 'filterEntryName') + '\n' + + extractFunc(src, 'filterSummary') + '\n' + + extractFunc(src, 'escapeHtml') + '\n' + + 'var _filterMenuSig = null;\n' + + extractFunc(src, 'renderFilterMenu') + '\n' + + 'function t(x) { return x; }\n' +); +let el = () => ({}); + +const INS = { + SELECT: 'a4', 'READ BINARY': 'b0', 'READ RECORD': 'b2', + 'UPDATE BINARY': 'd6', 'UPDATE RECORD': 'dc', + FETCH: '12', 'TERMINAL PROFILE': '10', ENVELOPE: 'c2', + 'GET RESPONSE': 'c0', +}; + +function msg(name, opts = {}) { + return { + type: opts.type || 'tpdu', + data: '', + decoded: { + ins_name: name, + ins_hex: INS[name] || '00', + sw: { sw1: opts.sw1 || '90', sw2: opts.sw2 || '00' }, + response_for: opts.response_for || null, + }, + }; +} + +test('an empty filter shows every message', () => { + STATE = { messages: [] }; + assert.equal(matchesFilter(msg('SELECT'), 0, new Set()), true); + assert.equal(matchesFilter(msg('SELECT'), 0, null), true); +}); + +test('a single command label filters to that command', () => { + const set = new Set(['cmd:SELECT']); + assert.equal(matchesFilter(msg('SELECT'), 0, set), true); + assert.equal(matchesFilter(msg('FETCH'), 0, set), false); +}); + +test('the GET RESPONSE linkage follows the selected commands', () => { + const cmd = msg('SELECT'); + const gr = msg('GET RESPONSE', { response_for: 'SELECT' }); + STATE = { messages: [cmd, gr] }; + // Selecting the command brings its GET RESPONSE along … + assert.equal(matchesFilter(gr, 1, new Set(['cmd:SELECT'])), true); + // … and selecting GET RESPONSE keeps the commands it belongs to. + assert.equal(matchesFilter(gr, 1, new Set(['cmd:GET RESPONSE'])), true); + assert.equal(matchesFilter(cmd, 0, new Set(['cmd:GET RESPONSE'])), true); + assert.equal(matchesFilter(msg('SELECT'), 0, new Set(['cmd:READ BINARY'])), false); +}); + +test('the proactive preset: FETCH, TERMINAL PROFILE and SW 91xx initiators', () => { + const set = new Set(['preset:proactive']); + assert.equal(matchesFilter(msg('FETCH'), 0, set), true); + assert.equal(matchesFilter(msg('TERMINAL RESPONSE'), 0, set), true); + assert.equal(matchesFilter(msg('TERMINAL PROFILE'), 0, set), true); + assert.equal(matchesFilter(msg('ENVELOPE', { sw1: '91', sw2: '30' }), 0, set), true); + assert.equal(matchesFilter(msg('UPDATE BINARY', { sw1: '91', sw2: '00' }), 0, set), true); + // Novelties: a plain ENVELOPE (90 00) and unrelated commands stay out. + assert.equal(matchesFilter(msg('ENVELOPE'), 0, set), false); + assert.equal(matchesFilter(msg('SELECT'), 0, set), false); +}); + +test('the proactive+events preset adds ENVELOPE', () => { + const set = new Set(['preset:proactive-events']); + assert.equal(matchesFilter(msg('ENVELOPE'), 0, set), true); + assert.equal(matchesFilter(msg('FETCH'), 0, set), true); + assert.equal(matchesFilter(msg('SELECT'), 0, set), false); +}); + +test('the file-operations preset covers the five commands and their replies', () => { + const set = new Set(['preset:files']); + for (const name of ['SELECT', 'READ BINARY', 'READ RECORD', 'UPDATE BINARY', + 'UPDATE RECORD']) { + assert.equal(matchesFilter(msg(name), 0, set), true, name); + } + assert.equal(matchesFilter(msg('FETCH'), 0, set), false); + // The GET RESPONSE carrying a SELECT's FCP belongs to the conversation. + assert.equal(matchesFilter(msg('GET RESPONSE', { response_for: 'SELECT' }), 0, set), + true); + assert.equal(matchesFilter(msg('GET RESPONSE', { response_for: 'FETCH' }), 0, set), + false); +}); + +test('entries combine as a union', () => { + const set = new Set(['preset:proactive', 'cmd:SELECT']); + assert.equal(matchesFilter(msg('FETCH'), 0, set), true); + assert.equal(matchesFilter(msg('SELECT'), 0, set), true); + assert.equal(matchesFilter(msg('READ RECORD'), 0, set), false); +}); + +test('the summary follows the selection', () => { + STATE = { filter: new Set() }; + assert.equal(filterSummary(), 'All commands'); + STATE.filter.add('cmd:SELECT'); + assert.equal(filterSummary(), 'SELECT'); + STATE.filter.clear(); + STATE.filter.add('preset:files'); + assert.equal(filterSummary(), 'File operations'); + STATE.filter.add('cmd:FETCH'); + assert.equal(filterSummary(), '2 commands'); +}); + +test('the menu lists the presets and the commands with their counts', () => { + STATE = { + messages: [msg('SELECT'), msg('SELECT'), msg('FETCH')], + filter: new Set(), + }; + const menu = { innerHTML: '', style: { display: 'none' } }; + const btn = { textContent: '' }; + el = id => (id === 'filter-menu' ? menu : btn); + renderFilterMenu(true); + assert.match(menu.innerHTML, /data-filter="preset:proactive"/); + assert.match(menu.innerHTML, /data-filter="preset:proactive-events"/); + assert.match(menu.innerHTML, /data-filter="preset:files"/); + assert.match(menu.innerHTML, /data-filter="cmd:SELECT"/); + assert.match(menu.innerHTML, /2<\/span>/); + assert.match(menu.innerHTML, /data-filter="all" checked>/); + assert.equal(btn.textContent, 'All commands'); + // A selection marks its row (and unchecks "All commands"). + STATE.filter.add('cmd:SELECT'); + renderFilterMenu(true); + assert.match(menu.innerHTML, /data-filter="cmd:SELECT" checked>/); + assert.match(menu.innerHTML, /data-filter="all">/); + assert.equal(btn.textContent, 'SELECT'); +}); + +test('the filter strings carry Russian translations', () => { + const lang = extractConst(src, 'LANG_RU'); + const keys = new Set(); + for (const m of lang.matchAll(/'((?:[^'\\]|\\.)*)'\s*:/g)) { + keys.add(m[1].replace(/\\(['\\])/g, '$1')); + } + for (const s of ['All commands', 'Presets', 'Commands', 'Proactive sessions', + 'Proactive + events', 'File operations', '{n} commands']) { + assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s); + } +}); + +test('the filter dropdown is wired (source pins)', () => { + assert.match(src, /