profiler: card snapshots (Снимки карты) list, scan, view

New read-only capture entity stored in localStorage 'otaman_snapshots':

- profiler list view is now two labelled columns: Profiles and Card
  snapshots; each snapshot row shows name, decoded ICCID, date, file
  count and Open / Export / Delete buttons
- New snapshot reuses the scan modal in a snapshot mode (name only, no
  ignore/mask/FCP options) via profilerScanCard(..., 'snapshot') ->
  profilerBuildSnapshotFile: metadata + raw FCI + exact contents for
  every readable file (no ignore list, no masking). The scan returns to
  the list afterwards.
- ICCID is decoded from EF.ICCID (2FE2) with decIccid() (nibble-swapped
  E.118 digits, F pad per TS 102 221 13.2) and stored as an immutable
  snapshot field, shown next to the name in the list and the view.
- Open shows all captured data read-only (attributes, raw FCI + decoded
  FCI, contents or 'Not captured'); only the name is editable/saveable.
- Import/Export/Delete + profilerValidateSnapshot; quota-safe save.

Tests: decIccid, profilerSnapshotIccid, validation, snapshot builder
(exact contents/no mask/unreadable), snapshot-mode scan with ICCID.
Docs synced (help/help-ru). SW cache v79 -> v80.
This commit is contained in:
2026-09-11 22:42:32 +03:00
parent aa6a9ed1b5
commit 71da10b637
5 changed files with 406 additions and 24 deletions
+291 -20
View File
@@ -813,13 +813,27 @@
<div id="pysim-sub-profiler" class="hidden">
<div id="profiler-list">
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
<button onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from card">Profile from card</button>
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-semibold mb-2 text-gray-700 dark:text-slate-300" data-l10n="Profiles">Profiles</div>
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
<button onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from card">Profile from card</button>
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
</div>
<div id="profiler-list-body"></div>
</div>
<div>
<div class="text-sm font-semibold mb-2 text-gray-700 dark:text-slate-300" data-l10n="Card snapshots">Card snapshots</div>
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="snapshotNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New snapshot">New snapshot</button>
<button onclick="document.getElementById('snapshot-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import snapshot">Import snapshot</button>
<input type="file" id="snapshot-import-file" accept=".json,application/json" class="hidden" onchange="snapshotImportFile(this)">
</div>
<div id="snapshot-list-body"></div>
</div>
</div>
<div id="profiler-list-body"></div>
</div>
<div id="profiler-editor" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
@@ -839,6 +853,15 @@
<div id="profiler-summary" class="mb-2 text-sm"></div>
<div id="profiler-report"></div>
</div>
<div id="profiler-snapshot" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
<input id="snapshot-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Snapshot name">
<span id="snapshot-iccid" class="text-xs font-mono text-gray-500 dark:text-slate-400"></span>
<button onclick="snapshotSaveName()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
</div>
<div id="snapshot-files"></div>
</div>
</div>
<div id="pysim-sub-cmd" class="hidden">
@@ -909,8 +932,8 @@
<div id="profiler-scan-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
<h3 id="profiler-scan-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
<label id="profiler-scan-name-label" class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
<input id="profiler-scan-name" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800 mb-3" placeholder="Profile name">
<div id="profiler-scan-options">
<div class="flex items-center gap-2 mb-1">
@@ -1419,6 +1442,11 @@ function encIccid(iccid) {
return swapNibbles(iccid);
}
// EF.ICCID (2FE2): nibble-swapped E.118 digits, 19-20 digits, 'F' pad (TS 102 221 13.2).
function decIccid(hex) {
return swapNibbles((hex || '').replace(/[^0-9a-fA-F]/g, '')).replace(/F+$/, '');
}
function buildApdu(cla, ins, p1, p2, p3, data) {
let s = cla.toString(16).padStart(2, '0').toUpperCase() +
ins.toString(16).padStart(2, '0').toUpperCase() +
@@ -6950,6 +6978,9 @@ let profilerView = 'list';
let profilerEditId = null;
let profilerDraft = null;
let profilerResults = null;
let snapshots = [];
let snapshotViewId = null;
let _scanTarget = 'profile';
// Files whose contents are ignored (contents check omitted) when scanning.
// Keyed by FID; display name is the pySim name.
@@ -6990,6 +7021,21 @@ function profilerSave() {
localStorage.setItem('otaman_profiles', JSON.stringify(profiles));
}
function snapshotsLoad() {
try {
const d = localStorage.getItem('otaman_snapshots');
snapshots = d ? JSON.parse(d) : [];
} catch (e) { snapshots = []; }
}
function snapshotsSave() {
try {
localStorage.setItem('otaman_snapshots', JSON.stringify(snapshots));
} catch (e) {
alert('Snapshot save failed: ' + e.message);
}
}
function profilerNewId() {
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
@@ -7095,7 +7141,31 @@ function profilerSetView(view) {
document.getElementById('profiler-list').classList.toggle('hidden', view !== 'list');
document.getElementById('profiler-editor').classList.toggle('hidden', view !== 'editor');
document.getElementById('profiler-results').classList.toggle('hidden', view !== 'results');
if (view === 'list') profilerRenderList();
document.getElementById('profiler-snapshot').classList.toggle('hidden', view !== 'snapshot');
if (view === 'list') { profilerRenderList(); profilerRenderSnapshotList(); }
}
function profilerRenderSnapshotList() {
const el = document.getElementById('snapshot-list-body');
if (!snapshots.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No snapshots defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < snapshots.length; i++) {
const s = snapshots[i];
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
html += '<b>' + esc(s.name) + '</b>';
if (s.iccid) html += '<span class="text-xs font-mono text-gray-500 dark:text-slate-400">' + esc(s.iccid) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(s.created).toLocaleString()) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + s.files.length + ' ' + esc(t('files')) + ')</span>';
html += '<span class="ml-auto flex gap-1">';
html += '<button onclick="snapshotOpen(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Open')) + '</button>';
html += '<button onclick="snapshotExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
html += '<button onclick="snapshotDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</span></div>';
}
el.innerHTML = html;
}
function profilerNew() {
@@ -7120,7 +7190,21 @@ function profilerScanIgnoreAllState() {
all.indeterminate = checked > 0 && checked < boxes.length;
}
function profilerScanSetTarget(target) {
_scanTarget = target;
const title = document.getElementById('profiler-scan-title');
const label = document.getElementById('profiler-scan-name-label');
const titleKey = target === 'snapshot' ? 'New snapshot' : 'Profile from card';
const labelKey = target === 'snapshot' ? 'Snapshot name' : 'Profile name';
title.setAttribute('data-l10n', titleKey);
title.textContent = t(titleKey);
label.setAttribute('data-l10n', labelKey);
label.textContent = t(labelKey);
document.getElementById('profiler-scan-options').classList.toggle('hidden', target === 'snapshot');
}
function profilerFromCard() {
profilerScanSetTarget('profile');
document.getElementById('profiler-scan-name').value = '';
document.getElementById('profiler-scan-name').readOnly = false;
document.getElementById('profiler-scan-name').classList.remove('opacity-50');
@@ -7154,11 +7238,25 @@ function profilerScanCancel() {
document.getElementById('profiler-scan-modal').classList.add('hidden');
}
function snapshotNew() {
profilerScanSetTarget('snapshot');
const nameEl = document.getElementById('profiler-scan-name');
nameEl.value = '';
nameEl.readOnly = false;
nameEl.classList.remove('opacity-50');
document.getElementById('profiler-scan-progress').classList.add('hidden');
document.getElementById('profiler-scan-error').classList.add('hidden');
document.getElementById('profiler-scan-cancel-btn').disabled = false;
document.getElementById('profiler-scan-btn').disabled = false;
document.getElementById('profiler-scan-btn').textContent = t('Scan');
document.getElementById('profiler-scan-modal').classList.remove('hidden');
}
async function profilerScanStart() {
const name = document.getElementById('profiler-scan-name').value.trim();
const errEl = document.getElementById('profiler-scan-error');
if (!name) {
errEl.textContent = t('Please enter a profile name');
errEl.textContent = t(_scanTarget === 'snapshot' ? 'Please enter a snapshot name' : 'Please enter a profile name');
errEl.classList.remove('hidden');
return;
}
@@ -7188,19 +7286,29 @@ async function profilerScanStart() {
btn.disabled = true;
btn.textContent = t('Scanning...');
try {
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, (done, total, path) => {
const onProgress = (done, total, path) => {
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
}, maskFids);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
profilerScanCancel();
profilerEdit(profiles.length - 1);
};
if (_scanTarget === 'snapshot') {
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot');
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files };
snapshots.push(snapshot);
snapshotsSave();
profilerScanCancel();
profilerSetView('list');
} else {
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
profilerScanCancel();
profilerEdit(profiles.length - 1);
}
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
document.getElementById('profiler-scan-options').classList.remove('hidden');
profilerScanSetTarget(_scanTarget);
cancelBtn.disabled = false;
nameEl.readOnly = false;
nameEl.classList.remove('opacity-50');
@@ -7214,7 +7322,7 @@ async function profilerScanStart() {
// Phase 1 discovers every file (tree calls only) to know the total; phase 2
// builds a rule per file, reporting progress via the optional onProgress
// callback as onProgress(done, total, path).
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids) {
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode) {
const rules = [];
const files = [];
const seen = new Set();
@@ -7268,7 +7376,9 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
for (let i = 0; i < files.length; i++) {
const entry = files[i];
if (onProgress) onProgress(i + 1, total, entry.path);
const rule = await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
const rule = mode === 'snapshot'
? await profilerBuildSnapshotFile(entry.path, entry.child)
: await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
if (rule) rules.push(rule);
}
return rules;
@@ -7317,6 +7427,45 @@ async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode,
return rule;
}
// Card snapshot entry: metadata + raw FCI + exact contents for every readable
// file (no ignore list, no masking, no FCP/FCI check mode).
async function profilerBuildSnapshotFile(path, c) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
} catch (e) { return null; }
if (!sel || sel.exists !== true) return null;
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
const file = {
path: path,
name: c.name || sel.name || null,
fileType: sel.file_type || null,
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
fciHex: sel.fci_hex || null,
content: null,
};
try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) {
if (rd.records) {
file.content = { kind: 'record', records: rd.records.map(r => ({ num: r.num, data: r.data })) };
} else if (rd.data) {
file.content = { kind: 'transparent', data: rd.data };
}
}
} catch (e) {}
return file;
}
// Decode the ICCID from the captured EF.ICCID (2FE2) entry, if readable.
function profilerSnapshotIccid(files) {
const f = (files || []).find(x => x.name === 'EF.ICCID' || (x.path || '').toUpperCase().endsWith('/2FE2'));
if (!f || !f.content || f.content.kind !== 'transparent' || !f.content.data) return null;
return decIccid(f.content.data) || null;
}
function profilerImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
@@ -7364,9 +7513,118 @@ function profilerEdit(i) {
function profilerBackToList() {
profilerDraft = null;
profilerEditId = null;
snapshotViewId = null;
profilerSetView('list');
}
function profilerValidateSnapshot(obj) {
if (!obj || typeof obj !== 'object') return 'Not an object';
if (!obj.name) return 'Missing name';
if (!Array.isArray(obj.files)) return 'Missing files array';
for (const f of obj.files) {
if (!f || typeof f !== 'object') return 'Invalid file entry';
if (!f.path) return 'File entry missing path';
}
return null;
}
function snapshotImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
const err = profilerValidateSnapshot(obj);
if (err) throw new Error(err);
obj.id = profilerNewId();
obj.created = obj.created || new Date().toISOString();
obj.iccid = obj.iccid || profilerSnapshotIccid(obj.files);
snapshots.push(obj);
snapshotsSave();
profilerRenderSnapshotList();
} catch (e) {
alert('Import error: ' + e.message);
}
};
reader.readAsText(file);
}
function snapshotExport(i) {
const s = snapshots[i];
downloadJson((s.name || 'snapshot').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', s);
}
function snapshotDelete(i) {
const s = snapshots[i];
if (!confirm(t('Delete snapshot') + ' "' + s.name + '"?') ) return;
snapshots.splice(i, 1);
snapshotsSave();
profilerRenderSnapshotList();
}
function snapshotOpen(i) {
const s = snapshots[i];
snapshotViewId = s.id;
profilerSetView('snapshot');
document.getElementById('snapshot-name').value = s.name;
document.getElementById('snapshot-iccid').textContent = s.iccid || '';
document.getElementById('snapshot-files').innerHTML = profilerRenderSnapshotFiles(s);
}
function snapshotSaveName() {
const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return;
const name = document.getElementById('snapshot-name').value.trim();
if (!name) return;
s.name = name;
snapshotsSave();
document.getElementById('snapshot-name').value = s.name;
}
function profilerRenderSnapshotFiles(s) {
if (!s.files.length) return '<span class="text-gray-400">' + t('No files captured.') + '</span>';
let html = '';
for (const f of s.files) {
const name = profilerCustomNameForPath(f.path) || f.name;
const title = name
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(f.path) + ')</span>'
: '<span>' + esc(f.path) + '</span>';
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="font-mono text-sm">' + title + '</div>';
const attrs = [];
if (f.fileType) attrs.push(esc(t('File type')) + ': ' + esc(f.fileType));
if (f.fileSize !== null && f.fileSize !== undefined) attrs.push(esc(t('Size')) + ': ' + esc(String(f.fileSize)));
if (f.recordLen !== null && f.recordLen !== undefined) attrs.push(esc(t('Record length')) + ': ' + esc(String(f.recordLen)));
if (f.numRecords !== null && f.numRecords !== undefined) attrs.push(esc(t('Record count')) + ': ' + esc(String(f.numRecords)));
if (attrs.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + attrs.join(' · ') + '</div>';
if (f.fciHex) {
html += '<div class="mt-1 flex gap-3 items-start">';
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' +
'<textarea readonly rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800 break-all">' + escHtml(f.fciHex) + '</textarea></div>';
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded FCI')) + '</div>' +
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex) + '</div></div>';
html += '</div>';
}
html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-1 mb-0.5">' + esc(t('Contents')) + '</div>';
if (!f.content) {
html += '<div class="text-xs text-gray-400 dark:text-slate-500">' + esc(t('Not captured')) + '</div>';
} else if (f.content.kind === 'record') {
html += '<div class="space-y-0.5">';
for (const rec of (f.content.records || [])) {
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8 shrink-0">' + rec.num + '</span>' +
'<input readonly value="' + escHtml(rec.data) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
}
html += '</div>';
} else {
html += '<input readonly value="' + escHtml(f.content.data || '') + '" class="w-full min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">';
}
html += '</div>';
}
return html;
}
function profilerAddRule() {
if (!profilerDraft) return;
profilerDraft.rules.push({ type: 'file', path: '', name: null, fileType: null, fileSize: null, recordLen: null, numRecords: null, fciMode: 'type_size', fciHex: null, content: null });
@@ -8082,6 +8340,7 @@ function profilerRenderReport(results) {
}
profilerLoad();
snapshotsLoad();
// Init sub-tab pills
document.querySelectorAll('.pysim-subtab').forEach(btn => {
@@ -8283,6 +8542,18 @@ const LANG_RU = {
'New profile': 'Новый профиль',
'Profile from card': 'Профиль с карты',
'Import profile': 'Импорт профиля',
'Profiles': 'Профили',
'Card snapshots': 'Снимки карты',
'New snapshot': 'Новый снимок',
'Import snapshot': 'Импорт снимка',
'Snapshot name': 'Имя снимка',
'Delete snapshot': 'Удалить снимок',
'No snapshots defined.': 'Снимки не заданы.',
'No files captured.': 'Файлы не захвачены.',
'Please enter a snapshot name': 'Введите имя снимка',
'Open': 'Открыть',
'Contents': 'Содержимое',
'Not captured': 'Не прочитано',
'Back to list': 'Назад к списку',
'Add rule': 'Добавить правило',
'Scan': 'Сканировать',