Files view: per-file tracking (counters, image, device/card changes)

- new `simtrace_analyser/filetrack.py`: a single-pass tracker over a
  session's decoded messages, keyed by (DF, FID) and trace-wide (resets only
  wipe the per-channel selection): counters (`select`/`read`/`update`,
  successful operations only), a sparse image of the observed bytes
  (transparent files by offset, record files per record number) and the
  change log
- changes: **device** = an UPDATE seen in the trace (applied to the image,
  `no_change` flags a provable no-op); **card** = a READ whose bytes differ
  from the image on bytes already known (logged at the surfacing read with
  the changed ranges and the before/after hex; never-seen bytes just fill
  the image, they are an observation, not a change)
- SFI-addressed accesses join the same entry as path/FID ones: the SFI ↔ FID
  relations come from the normative tables and from every surfaced FCP `88`
  DO (including the spec's "SFI = 5 LSBs of the FID" rule when the DO is
  absent); an SFI used before its relation is learned stays provisional and
  merges into the resolved entry
- the DF identity follows TS 102 221 §8.4/Table 8.2: `selected_df_fid()` now
  resolves an EF path selection to its parent DF and returns `aid:<hex>` for
  applications selected by an unknown AID (named from EF.DIR when the trace
  surfaced it); `Database._decode_rows` gained an `on_message` hook and
  `track_files()`
- `GET /api/sessions/<id>/files`: the operation timeline, the per-file
  counters/coverage/change log, the SFI relations and the AID labels
- PWA: the side panel's Map/Stats tabs became a view selector (Map / Stats /
  Files); the Files view is a timeline of Read / Update / Changed (card) rows
  (colour-coded, the same file names as the Map view), a row click jumps to
  the APDU (loading the pages it needs) and a card-change row's Δ opens the
  changed ranges with the previous and the new bytes; EN/RU
- bounds: the image freezes past 64 KB of observed bytes per file, the change
  log keeps the last 1000 events per file (counters keep counting)
- tests: tests/test_filetrack.py (counters, resets, channels, image/diff
  semantics, record files, SFI resolution and the provisional merge, EF.DIR
  labels, caps, the endpoint) and Node tests for the Files-view helpers and
  wiring
- v2.3.0; MIN_SERVER_VERSION 2.3.0; SW cache simtrace-analyser-v63
This commit is contained in:
catarrh committed 2026-10-17 20:51:33 +03:00
1 parent b8d9c4cc63
commit c494bf4111
14 files changed
+1302 -36

No files matched your search

+178 -20
View File
@@ -181,6 +181,20 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
.side-tabs { display: flex; margin-bottom: 8px; border-bottom: 1px solid var(--bg-input); }
.side-tab { flex: 1; padding: 6px 0; background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 2px solid transparent; }
.side-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.side-view-select { width: 100%; margin-bottom: 8px; padding: 6px 8px; background: var(--bg-input); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; }
.file-op { display: flex; align-items: baseline; gap: 6px; padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; word-break: break-word; }
.file-op:hover { background: var(--bg-input); }
.file-op-read { color: var(--text-muted); }
.file-op-update { color: var(--accent); }
.file-op-card { color: var(--warn); }
.file-op .file-op-diff { margin-left: auto; background: none; border: none; color: var(--warn); cursor: pointer; font-size: 12px; padding: 0 2px; flex: 0 0 auto; }
.file-op .file-op-diff:hover { color: var(--accent); }
.file-diff-modal { max-width: 640px; }
.file-diff-body { max-height: 50vh; overflow-y: auto; margin-bottom: 16px; }
.file-diff-body table { font-size: 12px; }
.file-diff-body td { padding: 2px 8px 2px 0; vertical-align: top; }
.file-diff-body .file-diff-hex { font-family: ui-monospace, monospace; word-break: break-all; }
.map-item { padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; color: var(--text); word-break: break-word; }
.map-item:hover { background: var(--bg-input); }
.map-files-toggle { color: var(--accent); cursor: pointer; }
@@ -333,12 +347,14 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
</div>
</div>
<div class="side-panel" id="side-panel">
<div class="side-tabs">
<button class="side-tab active" data-view="map" data-l10n="Map">Map</button>
<button class="side-tab" data-view="stats" data-l10n="Stats">Stats</button>
</div>
<select class="side-view-select" id="side-view-select" aria-label="Side panel view">
<option value="map" data-l10n="Map">Map</option>
<option value="stats" data-l10n="Stats">Stats</option>
<option value="files" data-l10n="Files">Files</option>
</select>
<div id="type-counts">—</div>
<div id="type-map" style="display:none">—</div>
<div id="file-view" style="display:none">—</div>
</div>
</div>
<div class="status-bar">
@@ -419,8 +435,19 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
</div>
</div>
<!-- COMPRION trace import dialog -->
<div class="modal-overlay" id="comprion-modal">
<!-- Card-side change detail (Files view) -->
<div class="modal-overlay" id="file-diff-modal">
<div class="modal file-diff-modal">
<h3 data-l10n="Card-side change">Card-side change</h3>
<p id="file-diff-sub"></p>
<div class="file-diff-body" id="file-diff-body"></div>
<div class="modal-buttons">
<button class="cancel" id="file-diff-close" data-l10n="Close">Close</button>
</div>
</div>
</div>
<!-- COMPRION trace import dialog --><div class="modal-overlay" id="comprion-modal">
<div class="modal comprion-modal">
<h3 data-l10n="Import COMPRION trace">Import COMPRION trace</h3>
<p data-l10n="Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.">Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.</p>
@@ -470,6 +497,8 @@ const STATE = {
typeCounts: [],
traceMap: [],
sideView: 'map',
filesData: null,
filesSessionId: null,
pendingDeleteId: null,
serverAvailable: false,
serverName: '',
@@ -1149,6 +1178,7 @@ const API = {
startCapture() { return this._fetch('POST', '/api/capture/start'); },
stopCapture() { return this._fetch('POST', '/api/capture/stop'); },
getLatest(after) { return this._fetch('GET', `/api/capture/latest?after=${after}`); },
getSessionFiles(id) { return this._fetch('GET', `/api/sessions/${id}/files`); },
searchMessages(sessionId, query) { return this._fetch('GET', `/api/apdu/search?session_id=${sessionId}&q=${encodeURIComponent(query)}`); },
filterMessages(sessionId, type) { return this._fetch('GET', `/api/apdu/filter?session_id=${sessionId}&type=${type}`); },
async _importPost(params, body, contentType) {
@@ -2026,11 +2056,111 @@ function renderSidePanel() {
}).join('') || t('No commands');
const showMap = STATE.sideView === 'map';
el('type-counts').style.display = showMap ? 'none' : '';
el('type-counts').style.display = STATE.sideView === 'stats' ? '' : 'none';
mapEl.style.display = showMap ? '' : 'none';
document.querySelectorAll('#side-panel .side-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === STATE.sideView);
});
el('file-view').style.display = STATE.sideView === 'files' ? '' : 'none';
el('side-view-select').value = STATE.sideView;
if (STATE.sideView === 'files') renderFileView();
}
// ──────────────────── Files view (per-file operation timeline) ────────────────────
function fileOpClass(kind) {
if (kind === 'update') return 'file-op-update';
if (kind === 'card_change') return 'file-op-card';
return 'file-op-read';
}
function fileOpVerb(kind) {
if (kind === 'update') return t('Update');
if (kind === 'card_change') return t('Changed (card)');
return t('Read');
}
function fileOpLabel(op) {
const name = (op.file && op.file.name) || '?';
return `${fileOpVerb(op.kind)} ${name}`;
}
// Split a change's concatenated before/after hex into per-range rows.
function fileDiffRows(change) {
const rows = [];
let at = 0;
for (const [pos, len] of (change.ranges || [])) {
const take = len * 2;
rows.push({
pos: pos,
len: len,
before: (change.before_hex || '').slice(at, at + take),
after: (change.after_hex || '').slice(at, at + take),
});
at += take;
}
return rows;
}
async function renderFileView() {
const view = el('file-view');
if (STATE.sessionViewId === null) {
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Open a saved session from the Sessions tab.')}</div>`;
return;
}
if (STATE.filesData === null || STATE.filesSessionId !== STATE.sessionViewId) {
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Loading…')}</div>`;
try {
STATE.filesData = await API.getSessionFiles(STATE.sessionViewId);
STATE.filesSessionId = STATE.sessionViewId;
} catch (e) {
STATE.filesData = null;
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Failed to load files')}</div>`;
return;
}
}
const data = STATE.filesData;
const timeline = (data && data.timeline) || [];
if (!timeline.length) {
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('No file operations recorded')}</div>`;
return;
}
view.innerHTML = timeline.map(op => {
const diff = (op.kind === 'card_change' && op.change_ref)
? `<button class="file-op-diff" data-diff="${escapeHtml(op.change_ref.file)}|${op.change_ref.idx}" title="${t('Show the change')}">Δ</button>`
: '';
return `<div class="file-op ${fileOpClass(op.kind)}" data-idx="${op.index}">
<span>${escapeHtml(fileOpLabel(op))}</span>${diff}
</div>`;
}).join('');
}
function showFileDiff(fileKey, idx) {
const data = STATE.filesData;
const entry = data && data.files ? data.files[fileKey] : null;
const change = entry && entry.changes ? entry.changes[idx] : null;
if (!change) return;
const rows = fileDiffRows(change);
el('file-diff-sub').textContent = `${entry.name} — ${change.op}`;
el('file-diff-body').innerHTML = rows.map(r => `<table>
<tr><td>${change.record != null ? t('Record') : t('Offset')}:</td><td>${escapeHtml(change.record != null ? String(r.pos) : '0x' + r.pos.toString(16).toUpperCase())} (${r.len} B)</td></tr>
<tr><td>${t('Before')}:</td><td class="file-diff-hex">${escapeHtml(r.before.toUpperCase())}</td></tr>
<tr><td>${t('After')}:</td><td class="file-diff-hex">${escapeHtml(r.after.toUpperCase())}</td></tr>
<tr><td colspan="2" style="height:6px"></td></tr>
</table>`).join('');
el('file-diff-modal').classList.add('show');
}
// Load the pages up to *idx* (the message list is fetched in pages) and focus
// the message — used by the Files timeline rows.
async function jumpToMessage(idx) {
if (STATE.sessionViewId === null || idx === null || idx === undefined) return;
try {
while (STATE.messages.length <= idx) {
const data = await API.getSession(STATE.sessionViewId, STATE.messages.length);
if (!data.messages || !data.messages.length) break;
STATE.messages.push(...data.messages);
}
renderMessages();
focusMessage(idx);
} catch (e) { /* keep the current view */ }
}
function mapFilesHtml(g) {
@@ -2652,6 +2782,8 @@ async function loadSession(id) {
const data = await API.getSession(id, 0);
STATE.sessionViewId = id;
STATE.filesData = null;
STATE.filesSessionId = null;
STATE.sessionStarted = data.session.started;
STATE.captureActive = false;
STATE.currentSessionName = data.session.name || t('Session #') + id;
@@ -2914,8 +3046,8 @@ function onFilterChange() {
}
// ──────────────────── Versioning ────────────────────
const PWA_VERSION = '2.2.0';
const MIN_SERVER_VERSION = '2.2.0';
const PWA_VERSION = '2.3.0';
const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) {
const parts = String(v || '').trim().split('.').map(n => parseInt(n, 10));
@@ -2957,6 +3089,20 @@ const LANG_RU = {
'Command Stats': 'Статистика команд',
'Stats': 'Статистика',
'Map': 'Обзор',
'Files': 'Файлы',
'Read': 'Чтение',
'Update': 'Запись',
'Changed (card)': 'Изменено (картой)',
'Card-side change': 'Изменение на карте',
'Close': 'Закрыть',
'Offset': 'Смещение',
'Record': 'Запись №',
'Before': 'Было',
'After': 'Стало',
'Show the change': 'Показать изменение',
'No file operations recorded': 'Операции с файлами не зафиксированы',
'Failed to load files': 'Не удалось загрузить список файлов',
'Loading…': 'Загрузка…',
'Reads:': 'Чтение:',
'Writes:': 'Запись:',
'Read/write:': 'Чтение/запись:',
@@ -3240,9 +3386,11 @@ function init() {
$on('comprion-cancel', 'click', closeComprionDialog);
$on('comprion-import', 'click', confirmComprionImport);
document.addEventListener('keydown', e => {
if (e.key === 'Escape'
&& el('comprion-modal').classList.contains('show')) {
if (e.key !== 'Escape') return;
if (el('comprion-modal').classList.contains('show')) {
closeComprionDialog();
} else if (el('file-diff-modal').classList.contains('show')) {
el('file-diff-modal').classList.remove('show');
}
});
@@ -3285,6 +3433,22 @@ function init() {
$on('install-btn', 'click', promptInstall);
$on('side-view-select', 'change', () => {
STATE.sideView = el('side-view-select').value;
renderSidePanel();
});
$on('file-view', 'click', e => {
const diff = e.target.closest('.file-op-diff');
if (diff) {
const parts = diff.dataset.diff.split('|');
showFileDiff(parts[0], Number(parts[1]));
return;
}
const row = e.target.closest('.file-op');
if (row) jumpToMessage(Number(row.dataset.idx));
});
$on('file-diff-close', 'click', () => el('file-diff-modal').classList.remove('show'));
$on('delete-cancel', 'click', () => {
el('delete-modal').classList.remove('show');
STATE.pendingDeleteId = null;
@@ -3292,12 +3456,6 @@ function init() {
$on('delete-confirm', 'click', confirmDelete);
el('side-panel').addEventListener('click', e => {
const tab = e.target.closest('.side-tab');
if (tab) {
STATE.sideView = tab.dataset.view;
renderSidePanel();
return;
}
const toggle = e.target.closest('.map-files-toggle');
if (toggle) {
e.stopPropagation();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v62';
const CACHE = 'simtrace-analyser-v63';
const PRECACHE = [
'index.html',
+63 -1
View File
@@ -124,7 +124,7 @@ test('the COMPRION import is a two-slot dialog', () => {
assert.match(src, /time_base === 'application-layer'/);
assert.match(src, /\$on\('import-comprion-btn', 'click', openComprionDialog\)/);
assert.match(src, /\$on\('comprion-cancel', 'click', closeComprionDialog\)/);
assert.match(src, /e\.key === 'Escape'/);
assert.match(src, /e\.key !== 'Escape'/);
});
test('comprionSlotCheck: slot/kind outcomes', () => {
@@ -275,3 +275,65 @@ test('COMPRION dialog: success closes, server errors stay', async () => {
closeComprionDialog();
});
// ── Files view (per-file operation timeline) ───────────────────────────
eval(
'function t(x) { return x; }\n' +
extractFunc(src, 'fileOpClass') + '\n' +
extractFunc(src, 'fileOpVerb') + '\n' +
extractFunc(src, 'fileOpLabel') + '\n' +
extractFunc(src, 'fileDiffRows')
);
test('Files view: operation kinds map to classes and verbs', () => {
assert.equal(fileOpClass('read'), 'file-op-read');
assert.equal(fileOpClass('update'), 'file-op-update');
assert.equal(fileOpClass('card_change'), 'file-op-card');
assert.equal(fileOpVerb('read'), 'Read');
assert.equal(fileOpVerb('update'), 'Update');
assert.equal(fileOpVerb('card_change'), 'Changed (card)');
assert.equal(fileOpLabel({kind: 'read', file: {name: 'EF_ICCID'}}), 'Read EF_ICCID');
assert.equal(fileOpLabel({kind: 'card_change', file: {name: 'EF_LOCI'}}),
'Changed (card) EF_LOCI');
assert.equal(fileOpLabel({kind: 'read', file: null}), 'Read ?');
});
test('Files view: the diff rows slice the concatenated hex', () => {
const change = {
ranges: [[8, 2], [16, 1]],
before_hex: '95f9aa',
after_hex: '000001',
};
assert.deepEqual(fileDiffRows(change), [
{pos: 8, len: 2, before: '95f9', after: '0000'},
{pos: 16, len: 1, before: 'aa', after: '01'},
]);
assert.deepEqual(fileDiffRows({ranges: [], before_hex: '', after_hex: ''}), []);
});
test('Files view: side-panel dropdown and the timeline are wired', () => {
assert.match(src, /<select class="side-view-select" id="side-view-select"[^>]*>/);
assert.match(src, /<option value="files" data-l10n="Files">/);
assert.doesNotMatch(src, /class="side-tab/);
assert.match(src, /id="file-view"/);
assert.match(src, /id="file-diff-modal"/);
assert.match(src, /id="file-diff-body"/);
assert.match(src, /\$on\('side-view-select', 'change'/);
assert.match(src, /\$on\('file-view', 'click'/);
assert.match(src, /API\.getSessionFiles\(STATE\.sessionViewId\)/);
assert.match(src, /if \(row\) jumpToMessage\(Number\(row\.dataset\.idx\)\);/);
assert.match(src, /getSessionFiles\(id\)\s*\{ return this\._fetch\('GET', `\/api\/sessions\/\$\{id\}\/files`\); \}/);
});
test('Files view: 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 ['Files', 'Read', 'Update', 'Changed (card)', 'Card-side change',
'Close', 'Offset', 'Record', 'Before', 'After', 'Show the change',
'No file operations recorded', 'Failed to load files', 'Loading…']) {
assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s);
}
});