Files view: the jumped-to record stays selected (v2.6.2)
- focusMessage()/scrollToType() highlighted the target and removed the class after 1500 ms, so a Files-row (or map/category) jump looked selected for a moment and then lost it; on top of that any re-render (the 500 ms live poll, a filter/search change) rebuilt the list and dropped the highlight — even for a clicked entry - the selection is now persistent app state (STATE.selectedIdx): the render marks the entry (gap rows included), focusMessage records the index before rendering, showDetail records it for plain clicks, scrollToType persists it too (clearing the previous highlight), and a new session/capture resets it so a stale index cannot mark an unrelated APDU - tests: a stub-driven focusMessage case (state + detail updated, no render and no timer), the filter-clearing case, and source pins for renderMessages, focusMessage, scrollToType, showDetail, loadSession and startCapture; 527 Python + 58 Node tests - README/README_RUS: the jump leaves the record selected; PWA_VERSION/ pyproject/version.py 2.6.2, SW cache simtrace-analyser-v76
This commit is contained in:
1 parent
a0a2251522
commit
76c5647dd4
8 files changed
+98
-19
No files matched your search
@@ -203,9 +203,10 @@ The Trace tab's side panel has a view selector (*Map* / *Stats* / **Files**;
|
|||||||
a drop-down, since three tab captions no longer fit). The Files view is a
|
a drop-down, since three tab captions no longer fit). The Files view is a
|
||||||
trace-wide timeline of file operations — every **Read**, **Update** and
|
trace-wide timeline of file operations — every **Read**, **Update** and
|
||||||
**Changed on card** row shows the same file name as the Map view, and clicking
|
**Changed on card** row shows the same file name as the Map view, and clicking
|
||||||
a row jumps straight to the APDU in the trace (the APDU list keeps the
|
a row jumps straight to the APDU in the trace and leaves it highlighted
|
||||||
reader's position: new messages pin the view to the bottom only while the
|
until another one is focused (the APDU list keeps the reader's position:
|
||||||
reader is already there, so a jump is never pulled away by the live poll). Rows that carry a recorded change (a
|
new messages pin the view to the bottom only while the reader is already
|
||||||
|
there, so a jump is never pulled away or un-highlighted by the live poll). Rows that carry a recorded change (a
|
||||||
card-side change, or an update that wrote or first observed bytes) also offer
|
card-side change, or an update that wrote or first observed bytes) also offer
|
||||||
**Details**: a modal showing the full file path (`MF/EF_ICCID (2FE2)`,
|
**Details**: a modal showing the full file path (`MF/EF_ICCID (2FE2)`,
|
||||||
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF
|
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF
|
||||||
|
|||||||
+4
-3
@@ -207,9 +207,10 @@ Application Layer* — два отдельных поля выбора, имен
|
|||||||
**Files** — выпадающий список, три подписи-вкладки уже не помещались). Вид
|
**Files** — выпадающий список, три подписи-вкладки уже не помещались). Вид
|
||||||
Files — это таймлайн файловых операций по всей трассе: строки **Read**,
|
Files — это таймлайн файловых операций по всей трассе: строки **Read**,
|
||||||
**Update** и **Changed on card** показывают те же имена файлов, что и вид Map,
|
**Update** и **Changed on card** показывают те же имена файлов, что и вид Map,
|
||||||
а щелчок по строке переходит прямо к APDU в трассе (список APDU сохраняет
|
а щелчок по строке переходит прямо к APDU в трассе и оставляет его
|
||||||
позицию читателя: новые сообщения прижимают вид к низу, только пока читатель
|
выделенным, пока не выбран другой (список APDU сохраняет позицию читателя:
|
||||||
уже внизу, поэтому переход не срывается живым опросом). У строк с записанным изменением
|
новые сообщения прижимают вид к низу, только пока читатель уже внизу,
|
||||||
|
поэтому переход не срывается живым опросом и не снимает выделение). У строк с записанным изменением
|
||||||
(изменение со стороны карты либо запись, изменившая или впервые показавшая
|
(изменение со стороны карты либо запись, изменившая или впервые показавшая
|
||||||
байты) есть кнопка **Details**: окно показывает полный путь файла (`MF/EF_ICCID (2FE2)`,
|
байты) есть кнопка **Details**: окно показывает полный путь файла (`MF/EF_ICCID (2FE2)`,
|
||||||
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF
|
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF
|
||||||
|
|||||||
+19
-8
@@ -505,6 +505,7 @@ const STATE = {
|
|||||||
sessionStarted: null,
|
sessionStarted: null,
|
||||||
livePoller: null,
|
livePoller: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
selectedIdx: null, // the focused APDU entry (persists across renders)
|
||||||
typeCounts: [],
|
typeCounts: [],
|
||||||
traceMap: [],
|
traceMap: [],
|
||||||
sideView: 'map',
|
sideView: 'map',
|
||||||
@@ -1754,9 +1755,13 @@ function atrPpsRows(decoded) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(msg, entry) {
|
function showDetail(msg, entry) {
|
||||||
// Highlight clicked entry
|
// Highlight the focused entry; the state keeps it marked across renders.
|
||||||
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
|
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
|
||||||
if (entry) entry.classList.add('selected');
|
if (entry) {
|
||||||
|
entry.classList.add('selected');
|
||||||
|
const idx = Number(entry.dataset.idx);
|
||||||
|
if (!Number.isNaN(idx)) STATE.selectedIdx = idx;
|
||||||
|
}
|
||||||
|
|
||||||
const d = el('apdu-detail');
|
const d = el('apdu-detail');
|
||||||
const decoded = msg.decoded;
|
const decoded = msg.decoded;
|
||||||
@@ -2333,9 +2338,10 @@ function scrollToType(typeKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetEl) {
|
if (targetEl) {
|
||||||
|
STATE.selectedIdx = parseInt(targetEl.dataset.idx);
|
||||||
targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
|
||||||
targetEl.classList.add('selected');
|
targetEl.classList.add('selected');
|
||||||
setTimeout(() => targetEl.classList.remove('selected'), 1500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2345,12 +2351,13 @@ function focusMessage(idx) {
|
|||||||
const needRender = filterEl.value !== '' || searchEl.value !== '';
|
const needRender = filterEl.value !== '' || searchEl.value !== '';
|
||||||
filterEl.value = '';
|
filterEl.value = '';
|
||||||
searchEl.value = '';
|
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.
|
||||||
|
STATE.selectedIdx = idx;
|
||||||
if (needRender) renderMessages(true);
|
if (needRender) renderMessages(true);
|
||||||
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
|
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
entry.classList.add('selected');
|
|
||||||
setTimeout(() => entry.classList.remove('selected'), 1500);
|
|
||||||
showDetail(STATE.messages[idx], entry);
|
showDetail(STATE.messages[idx], entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2432,9 +2439,11 @@ function renderMessages(skipScroll, forceStick) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedIdx = STATE.selectedIdx;
|
||||||
list.innerHTML = filtered.map(({ m, idx }) => {
|
list.innerHTML = filtered.map(({ m, idx }) => {
|
||||||
|
const selCls = idx === selectedIdx ? ' selected' : '';
|
||||||
if (m.type === 'gap') {
|
if (m.type === 'gap') {
|
||||||
return `<div class="apdu-entry apdu-gap" data-idx="${idx}">
|
return `<div class="apdu-entry apdu-gap${selCls}" data-idx="${idx}">
|
||||||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||||||
<span class="apdu-type">⚠ CAPTURE GAP</span>
|
<span class="apdu-type">⚠ CAPTURE GAP</span>
|
||||||
<span class="apdu-data">${cmd_description(m)}</span>
|
<span class="apdu-data">${cmd_description(m)}</span>
|
||||||
@@ -2454,7 +2463,7 @@ function renderMessages(skipScroll, forceStick) {
|
|||||||
m.decoded.ins_name === 'GET RESPONSE' && m.decoded.response_for;
|
m.decoded.ins_name === 'GET RESPONSE' && m.decoded.response_for;
|
||||||
const pairCls = (isGR || isOriginalCommand(idx)) ? ' apdu-pair' : '';
|
const pairCls = (isGR || isOriginalCommand(idx)) ? ' apdu-pair' : '';
|
||||||
const grLabel = isGR ? '↳ ' + label : label;
|
const grLabel = isGR ? '↳ ' + label : label;
|
||||||
return `<div class="apdu-entry${pairCls}" data-idx="${idx}">
|
return `<div class="apdu-entry${pairCls}${selCls}" data-idx="${idx}">
|
||||||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||||||
<span class="apdu-type ${typeCls}">${grLabel}</span>
|
<span class="apdu-type ${typeCls}">${grLabel}</span>
|
||||||
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
|
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
|
||||||
@@ -2561,6 +2570,7 @@ async function startCapture() {
|
|||||||
STATE.currentSessionId = data.session_id;
|
STATE.currentSessionId = data.session_id;
|
||||||
STATE.sessionStarted = data.started;
|
STATE.sessionStarted = data.started;
|
||||||
STATE.messages = [];
|
STATE.messages = [];
|
||||||
|
STATE.selectedIdx = null;
|
||||||
_lastAfter = 0;
|
_lastAfter = 0;
|
||||||
STATE.mapExpanded.clear();
|
STATE.mapExpanded.clear();
|
||||||
buildTypeCounts();
|
buildTypeCounts();
|
||||||
@@ -2943,6 +2953,7 @@ async function loadSession(id) {
|
|||||||
STATE.filesData = null;
|
STATE.filesData = null;
|
||||||
STATE.filesSessionId = null;
|
STATE.filesSessionId = null;
|
||||||
STATE.filesImages = {};
|
STATE.filesImages = {};
|
||||||
|
STATE.selectedIdx = null;
|
||||||
STATE.sessionStarted = data.session.started;
|
STATE.sessionStarted = data.session.started;
|
||||||
STATE.captureActive = false;
|
STATE.captureActive = false;
|
||||||
STATE.currentSessionName = data.session.name || t('Session #') + id;
|
STATE.currentSessionName = data.session.name || t('Session #') + id;
|
||||||
@@ -3205,7 +3216,7 @@ function onFilterChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── Versioning ────────────────────
|
// ──────────────────── Versioning ────────────────────
|
||||||
const PWA_VERSION = '2.6.1';
|
const PWA_VERSION = '2.6.2';
|
||||||
const MIN_SERVER_VERSION = '2.3.0';
|
const MIN_SERVER_VERSION = '2.3.0';
|
||||||
|
|
||||||
function parseVersion(v) {
|
function parseVersion(v) {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simtrace-analyser-v75';
|
const CACHE = 'simtrace-analyser-v76';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'index.html',
|
'index.html',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ let STATE = {};
|
|||||||
let API = {};
|
let API = {};
|
||||||
let renders = [];
|
let renders = [];
|
||||||
let focused = [];
|
let focused = [];
|
||||||
|
let el = () => ({});
|
||||||
|
let showDetail = () => {};
|
||||||
function renderMessages(skipScroll, forceStick) {
|
function renderMessages(skipScroll, forceStick) {
|
||||||
renders.push([skipScroll, forceStick]);
|
renders.push([skipScroll, forceStick]);
|
||||||
}
|
}
|
||||||
@@ -21,8 +23,11 @@ function focusMessage(idx) {
|
|||||||
}
|
}
|
||||||
eval(
|
eval(
|
||||||
extractFunc(src, 'jumpToMessage') + '\n' +
|
extractFunc(src, 'jumpToMessage') + '\n' +
|
||||||
extractFunc(src, 'shouldStickToBottom')
|
extractFunc(src, 'shouldStickToBottom') + '\n'
|
||||||
);
|
);
|
||||||
|
// The real focusMessage, under an alias: the stub above stays in place for
|
||||||
|
// the jumpToMessage tests.
|
||||||
|
eval('var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';');
|
||||||
|
|
||||||
test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
|
test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
|
||||||
renders = [];
|
renders = [];
|
||||||
@@ -81,3 +86,64 @@ test('a new session/capture lands at the bottom (source pins)', () => {
|
|||||||
assert.match(extractFunc(src, 'loadSession'), /renderMessages\(false, true\);/);
|
assert.match(extractFunc(src, 'loadSession'), /renderMessages\(false, true\);/);
|
||||||
assert.match(extractFunc(src, 'startCapture'), /renderMessages\(false, true\);/);
|
assert.match(extractFunc(src, 'startCapture'), /renderMessages\(false, true\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── the focused record stays selected ──────────────────────────────────
|
||||||
|
|
||||||
|
test('focusMessage marks the record persistently and updates the detail', () => {
|
||||||
|
renders = [];
|
||||||
|
const classes = new Set();
|
||||||
|
const entry = {
|
||||||
|
dataset: { idx: '1' },
|
||||||
|
classList: { add: c => classes.add(c), remove: c => classes.delete(c) },
|
||||||
|
scrollIntoView: () => {},
|
||||||
|
};
|
||||||
|
STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0 };
|
||||||
|
el = () => ({ value: '', querySelector: () => entry });
|
||||||
|
let detailed = null;
|
||||||
|
showDetail = (msg, e) => { detailed = [msg, e]; };
|
||||||
|
realFocusMessage(1);
|
||||||
|
assert.equal(STATE.selectedIdx, 1);
|
||||||
|
assert.deepEqual(detailed, [{ id: 'b' }, entry]);
|
||||||
|
// No render is needed when no filter is active — and no flash timeout
|
||||||
|
// is scheduled: the highlight is owned by showDetail/the state.
|
||||||
|
assert.deepEqual(renders, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focusMessage clears a filter before focusing', () => {
|
||||||
|
renders = [];
|
||||||
|
const entry = {
|
||||||
|
dataset: { idx: '3' },
|
||||||
|
classList: { add: () => {}, remove: () => {} },
|
||||||
|
scrollIntoView: () => {},
|
||||||
|
};
|
||||||
|
STATE = { messages: {}, selectedIdx: null };
|
||||||
|
el = () => ({ value: 'x', querySelector: () => entry });
|
||||||
|
showDetail = () => {};
|
||||||
|
realFocusMessage(3);
|
||||||
|
assert.deepEqual(renders, [[true, undefined]]);
|
||||||
|
assert.equal(STATE.selectedIdx, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the selection persists across renders (source pins)', () => {
|
||||||
|
const render = extractFunc(src, 'renderMessages');
|
||||||
|
assert.match(render, /const selectedIdx = STATE\.selectedIdx;/);
|
||||||
|
assert.match(render, /idx === selectedIdx \? ' selected' : ''/);
|
||||||
|
assert.match(render, /apdu-entry apdu-gap\$\{selCls\}/);
|
||||||
|
assert.match(render, /apdu-entry\$\{pairCls\}\$\{selCls\}/);
|
||||||
|
|
||||||
|
const focus = extractFunc(src, 'focusMessage');
|
||||||
|
assert.match(focus, /STATE\.selectedIdx = idx;/);
|
||||||
|
assert.doesNotMatch(focus, /setTimeout/);
|
||||||
|
|
||||||
|
const type = extractFunc(src, 'scrollToType');
|
||||||
|
assert.match(type, /STATE\.selectedIdx = parseInt\(targetEl\.dataset\.idx\);/);
|
||||||
|
assert.doesNotMatch(type, /setTimeout/);
|
||||||
|
|
||||||
|
const detail = extractFunc(src, 'showDetail');
|
||||||
|
assert.match(detail, /entry\.classList\.add\('selected'\)/);
|
||||||
|
assert.match(detail, /STATE\.selectedIdx = idx;/);
|
||||||
|
|
||||||
|
// A new session/capture must not keep a stale index selected.
|
||||||
|
assert.match(extractFunc(src, 'loadSession'), /STATE\.selectedIdx = null;/);
|
||||||
|
assert.match(extractFunc(src, 'startCapture'), /STATE\.selectedIdx = null;/);
|
||||||
|
});
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "simtrace-analyser"
|
name = "simtrace-analyser"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
|
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
|
||||||
readme = {file = "README.md", content-type = "text/markdown"}
|
readme = {file = "README.md", content-type = "text/markdown"}
|
||||||
license = {text = "GPL-2.0-or-later"}
|
license = {text = "GPL-2.0-or-later"}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the SIMtrace Analyser version."""
|
"""Single source of truth for the SIMtrace Analyser version."""
|
||||||
|
|
||||||
__version__ = '2.6.1'
|
__version__ = '2.6.2'
|
||||||
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
|
|||||||
class TestVersion(unittest.TestCase):
|
class TestVersion(unittest.TestCase):
|
||||||
def test_package_version(self):
|
def test_package_version(self):
|
||||||
from simtrace2_pysniff import __version__
|
from simtrace2_pysniff import __version__
|
||||||
self.assertEqual(__version__, '2.6.1')
|
self.assertEqual(__version__, '2.6.2')
|
||||||
|
|
||||||
def test_status_includes_version(self):
|
def test_status_includes_version(self):
|
||||||
import os
|
import os
|
||||||
|
|||||||
Reference in new issue
Block a user