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:
1 parent
b8d9c4cc63
commit
c494bf4111
14 files changed
+1302
-36
No files matched your search
+178
-20
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simtrace-analyser-v62';
|
||||
const CACHE = 'simtrace-analyser-v63';
|
||||
|
||||
const PRECACHE = [
|
||||
'index.html',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user