From e0134c769a24c9c8cd5766736baa3e4ac4d78e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 17 Oct 2026 23:55:28 +0300 Subject: [PATCH] Command filter: multi-select with conversation presets (v2.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the single-command - - + 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, /