Files
simtrace-analyser/frontend/index.html
T

2153 lines
99 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIMtrace Analyser</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="sim-analyser.svg">
<link rel="icon" type="image/png" href="favicon.png">
<meta name="theme-color" content="#0f172a">
<style>
:root, [data-theme="dark"] {
--bg: #0f172a;
--bg-panel: #1e293b;
--bg-input: #334155;
--border: #475569;
--text: #e2e8f0;
--text-muted: #94a3b8;
--text-dim: #cbd5e1;
--text-faint: #cbd5e1;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-bg: #14532d;
--warn-bg: #7f1d1d;
--warn: #facc15;
--warn-hover: #eab308;
--warning-bg: #78350f;
--error: #f87171;
--code-bg: #1e293b;
--scrollbar-track: #0f172a;
--scrollbar-thumb: #334155;
--modal-bg: rgba(0,0,0,0.6);
--green: #22c55e;
--yellow: #facc15;
--link: #94a3b8;
--link-hover: #e2e8f0;
}
[data-theme="light"] {
--bg: #fafafa;
--bg-panel: #e5e7eb;
--bg-input: #ffffff;
--border: #d1d5db;
--text: #1f2937;
--text-muted: #6b7280;
--text-dim: #4b5563;
--text-faint: #374151;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-bg: #dcfce7;
--warn-bg: #fee2e2;
--warn: #f59e0b;
--warn-hover: #d97706;
--warning-bg: #fef3c7;
--error: #dc2626;
--code-bg: #f3f4f6;
--scrollbar-track: #fafafa;
--scrollbar-thumb: #d1d5db;
--modal-bg: rgba(0,0,0,0.4);
--green: #16a34a;
--yellow: #ca8a04;
--link: #6b7280;
--link-hover: #1f2937;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* Header */
.header { display: flex; background: var(--bg-panel); border-bottom: 1px solid var(--border); }
.header-tabs { display: flex; gap: 4px; padding: 8px 16px 0 16px; align-items: flex-end; }
.header-tab { padding: 6px 16px; cursor: pointer; color: var(--text-muted); background: var(--bg-input); border-radius: 6px 6px 0 0; font-size: 14px; }
.header-tab:hover { background: var(--border); }
.header-tab.active { background: var(--accent); color: #fff; }
.header-title { margin: 0 auto; display: flex; align-items: center; gap: 6px; color: var(--text); font-weight: 600; font-size: 14px; white-space: nowrap; }
.header-title .pwa-version { color: var(--text-muted); font-size: 11px; font-weight: 400; }
.header-title a { color: var(--accent); text-decoration: none; }
.header-title a:hover { text-decoration: underline; }
.header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; padding: 8px 16px; }
.capture-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 12px; white-space: nowrap; }
.capture-chip.live { color: var(--green); background: var(--accent-bg); }
.capture-chip.warn { color: var(--warn); background: var(--warning-bg); }
.header-right a { color: var(--link); text-decoration: none; font-size: 13px; }
.header-right a:hover { color: var(--link-hover); }
.header-right button { padding: 4px 10px; font-size: 14px; cursor: pointer; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
.header-right button:hover { background: var(--accent); color: #fff; }
/* Panels */
.panel { flex: 1; display: none; flex-direction: column; overflow: hidden; }
.panel.active { display: flex; }
/* Trace view */
.trace-topbar { display: flex; gap: 12px; padding: 12px 16px; background: var(--bg-panel); align-items: center; flex-shrink: 0; }
.trace-topbar input { flex: 0 1 33.333%; padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
.trace-topbar input::placeholder { color: var(--text-muted); }
.trace-topbar select { padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
.trace-topbar .filter-label { color: var(--text-muted); font-size: 13px; white-space: nowrap; }
.trace-topbar button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
.trace-topbar button:hover { background: var(--accent-hover); }
.trace-topbar button.stop { background: var(--bg-input); color: var(--text); }
.trace-topbar button.stop:hover { background: var(--bg-panel); }
.trace-topbar button.connect { background: var(--warn); color: #0f172a; }
.trace-topbar button.connect:hover { background: var(--warn-hover); }
.trace-main { display: flex; flex: 1; overflow: hidden; }
.trace-left { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* APDU list */
.apdu-list { flex: 1; overflow-y: auto; padding: 8px; }
.apdu-entry { display: flex; gap: 12px; padding: 3px 12px; border-bottom: 1px solid var(--bg-panel); cursor: pointer; align-items: baseline; }
.apdu-entry:hover { background: var(--bg-panel); }
.apdu-entry.selected { background: var(--bg-input); }
[data-theme="light"] .apdu-entry.selected { background: #dbeafe; }
.apdu-time { color: var(--text-muted); font-family: monospace; white-space: nowrap; min-width: 80px; }
.apdu-type { color: var(--text); font-weight: 600; min-width: 150px; }
.apdu-type.atr { color: var(--green); }
.apdu-type.pps { color: var(--yellow); }
.apdu-data { font-family: monospace; color: var(--text-dim); word-break: break-all; flex: 1; }
.apdu-sw { font-family: monospace; color: var(--text-muted); white-space: nowrap; min-width: 52px; }
.apdu-sw.sw-ok { color: var(--green); }
.apdu-sw.sw-pending { color: var(--accent); }
.apdu-sw.sw-warn { color: var(--warn); }
.apdu-sw.sw-error { color: var(--error); }
.apdu-err { color: var(--error); white-space: nowrap; }
.apdu-gap { background: var(--warning-bg); border-bottom: 1px solid var(--warn); }
.apdu-gap .apdu-type { color: var(--warn); }
.apdu-gap .apdu-data { color: var(--warn); }
.device-warn { padding: 8px 12px; font-size: 12px; color: var(--warn); background: var(--warning-bg); border-bottom: 1px solid var(--warn); }
/* APDU detail panel */
.apdu-detail { display: flex; flex-direction: column; max-height: 40%; overflow-y: auto; border-top: 2px solid var(--accent); background: var(--bg-panel); flex-shrink: 0; }
.apdu-detail .detail-empty { padding: 24px; color: var(--text-muted); text-align: center; }
.detail-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: var(--bg-input); border-bottom: 1px solid var(--border); }
[data-theme="light"] .detail-header { background: #dbeafe; }
.detail-header strong { font-size: 13px; }
.detail-body { padding: 8px 12px; font-family: monospace; font-size: 12px; line-height: 1.6; }
.detail-body table { width: 100%; border-collapse: collapse; }
.detail-body td { padding: 1px 8px; vertical-align: top; }
.detail-body td.label { color: var(--text-muted); white-space: nowrap; width: 80px; }
.detail-body td.value { color: var(--text); }
.detail-body td.hex { color: var(--text-dim); word-break: break-all; }
.detail-body td.name { color: var(--accent); font-weight: 600; }
/* Side panel */
.side-panel { width: 260px; background: var(--bg-panel); padding: 12px; overflow-y: auto; flex-shrink: 0; border-left: 1px solid var(--bg-input); }
.side-panel h3 { color: var(--accent); margin-bottom: 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; }
.side-item { display: flex; justify-content: space-between; padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; }
.side-item:hover { background: var(--bg-input); }
.side-item .count { color: var(--accent); font-weight: 600; }
.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); }
.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; }
.map-files-toggle .map-arrow { color: var(--text-muted); }
.map-item.map-proactive { font-weight: 700; color: #a78bfa; }
[data-theme="light"] .map-item.map-proactive { color: #6d28d9; }
.map-item.map-tr { font-weight: 700; color: #22d3ee; }
[data-theme="light"] .map-item.map-tr { color: #0e7490; }
.map-item.map-envelope { font-weight: 600; color: #fbbf24; }
[data-theme="light"] .map-item.map-envelope { color: #b45309; }
.map-item.map-auth { font-weight: 600; color: #f87171; }
[data-theme="light"] .map-item.map-auth { color: #dc2626; }
/* Status bar */
.status-bar { display: flex; padding: 8px 16px; background: var(--bg-panel); border-top: 1px solid var(--bg-input); font-size: 13px; gap: 16px; flex-shrink: 0; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-dot.live { background: var(--green); }
.status-dot.paused { background: var(--yellow); }
.status-dot.off { background: var(--text-muted); }
/* Sessions */
.sessions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; padding: 16px; overflow-y: auto; }
.sessions-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px 0; gap: 12px; }
.sessions-header .sessions-stats { color: var(--text-muted); font-size: 13px; }
.sessions-header .import-msg { margin-left: auto; font-size: 13px; color: var(--text-muted); }
.sessions-header .import-msg.error { color: var(--error); }
.sessions-header button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
.sessions-header button:hover { background: var(--accent-hover); }
.session-card { background: var(--bg-panel); border-radius: 8px; padding: 16px; cursor: pointer; border: 1px solid var(--bg-input); }
.session-card:hover { border-color: var(--accent); }
.session-name { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.session-name input { flex: 1; padding: 4px 8px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; font-size: 14px; }
.session-name input:focus { border-color: var(--accent); outline: none; }
.session-name .edit-hint { color: var(--text-muted); font-size: 12px; }
.session-meta { display: flex; gap: 16px; color: var(--text-muted); font-size: 12px; }
.session-meta span { display: flex; align-items: center; gap: 4px; }
.session-actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; }
.session-download { padding: 4px 12px; background: none; border: 1px solid var(--text-muted); color: var(--text-muted); border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 13px; display: inline-block; }
.session-download:hover { border-color: var(--accent); color: var(--accent); }
.session-delete { padding: 4px 12px; background: none; border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer; font-size: 13px; }
.session-delete:hover { background: var(--accent); color: #fff; }
/* Settings */
.settings-view { padding: 24px; max-width: 600px; }
.settings-view label { display: block; margin-bottom: 6px; color: var(--text-muted); }
.settings-view input { width: 100%; padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; }
.settings-view input::placeholder { color: var(--text-muted); }
.settings-url-row { display: flex; gap: 8px; margin-bottom: 16px; }
.settings-url-row input { flex: 1; margin-bottom: 0; }
.settings-url-row select { flex: 0 0 auto; padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; }
.settings-url-row button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; white-space: nowrap; }
.settings-url-row button:hover { background: var(--accent-hover); }
.settings-url-row button.disconnected { background: var(--bg-input); }
.conn-status { padding: 12px; border-radius: 4px; margin-bottom: 12px; }
.conn-status.connected { background: var(--accent-bg); color: var(--green); }
.conn-status.disconnected { background: var(--warn-bg); color: var(--error); }
.conn-hint { font-size: 12px; color: var(--text-muted); margin: 8px 0 0; line-height: 1.5; }
.capture-mode { padding: 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; line-height: 1.5; }
.capture-mode:empty { display: none; }
.capture-mode.gsmtap { background: var(--warning-bg); color: var(--warn); }
.capture-mode.direct { background: var(--accent-bg); color: var(--green); }
.capture-mode.disabled { background: var(--warning-bg); color: var(--warn); }
.version-warn { font-size: 12px; color: var(--warn); margin: 8px 0 0; line-height: 1.5; }
.version-warn:empty { display: none; }
.conn-instructions { margin-top: 16px; font-size: 13px; color: var(--text-faint); line-height: 1.7; }
.conn-instructions h3 { color: var(--accent); margin-bottom: 8px; font-size: 14px; }
.conn-instructions ol { padding-left: 20px; }
.conn-instructions li { margin-bottom: 8px; }
.conn-instructions code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
.conn-instructions p { margin-top: 12px; }
/* Empty state */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); gap: 16px; }
.empty-state button { padding: 12px 24px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
.empty-state .conn-status, .empty-state .capture-mode { width: 100%; max-width: 560px; text-align: left; }
/* Modal */
.modal-overlay { display: none; position: fixed; inset: 0; background: var(--modal-bg); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: var(--bg-panel); border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; }
.modal h3 { margin-bottom: 12px; color: var(--accent); }
.modal p { color: var(--text-faint); margin-bottom: 16px; }
.modal-buttons { display: flex; gap: 8px; justify-content: flex-end; }
.modal-buttons button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
.modal-buttons .confirm { background: var(--accent); color: #fff; }
.modal-buttons .cancel { background: var(--bg-input); color: var(--text-faint); }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<div class="header-tabs">
<div class="header-tab active" data-tab="trace" data-l10n="Trace">Trace</div>
<div class="header-tab" data-tab="sessions" data-l10n="Sessions">Sessions</div>
<div class="header-tab" data-tab="settings" data-l10n="Settings">Settings</div>
</div>
<div class="header-title">SIM-ME <a href="https://osmocom.org/projects/baseband/wiki/GSMTAP" target="_blank" rel="noopener">GSMTAP</a> <span data-l10n="trace analyser">trace analyser</span> <span class="pwa-version" id="pwa-version"></span></div>
<div class="header-right">
<span class="capture-chip" id="capture-chip" style="display:none"></span>
<button id="install-btn" data-l10n="Install PWA">Install PWA</button>
<a href="https://github.com/anttro/simtrace2-pysniff" target="_blank" rel="noopener">GitHub</a>
<button id="lang-btn" onclick="toggleLang()" title="Language / Язык">EN</button>
<button id="theme-btn" onclick="toggleTheme()">☀️</button>
</div>
</div><!-- Trace Panel -->
<div class="panel active" id="panel-trace">
<div class="trace-topbar">
<input type="text" id="search-input" data-l10n="Search hex bytes..." placeholder="Search hex bytes..." autocomplete="off">
<label for="filter-select" class="filter-label" data-l10n="Filter">Filter</label>
<select id="filter-select">
<option value="" data-l10n="All commands">All commands</option>
</select>
<button id="capture-btn" data-l10n="Start Capture">Start Capture</button>
<span id="session-name-display" style="display:none; color:var(--text-muted); font-size:15px; font-weight:700;"></span>
</div>
<div class="trace-main">
<div class="trace-left">
<div class="apdu-list" id="apdu-list"></div>
<div class="apdu-detail" id="apdu-detail">
<div class="detail-empty" data-l10n="Click an APDU to see details">Click an APDU to see details</div>
</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>
<div id="type-counts">—</div>
<div id="type-map" style="display:none">—</div>
</div>
</div>
<div class="status-bar">
<span><span class="status-dot off" id="status-dot"></span><span id="status-text" data-l10n="Not connected">Not connected</span></span>
<span id="msg-count" style="color:var(--text-muted);">0 messages</span>
</div>
<div class="device-warn" id="device-warn" style="display:none">⚠ <span id="device-warn-text"></span></div>
</div>
<!-- Sessions Panel -->
<div class="panel" id="panel-sessions">
<div class="sessions-header">
<span class="sessions-stats" id="sessions-stats"></span>
<span class="import-msg" id="import-msg"></span>
<button id="import-pcap-btn" data-l10n="Import PCAP">Import PCAP</button>
<button id="new-capture-btn" data-l10n="New capture">New capture</button>
<input type="file" id="import-pcap-file" accept=".pcap,.pcapng,.cap" style="display:none;">
</div>
<div class="sessions-grid" id="sessions-grid">
<div class="empty-state" style="height:auto; padding:40px;">
<p data-l10n="No saved sessions">No saved sessions</p>
<p style="font-size:12px;" data-l10n="Start a capture from the Trace tab to create sessions.">Start a capture from the Trace tab to create sessions.</p>
</div>
</div>
</div>
<!-- Settings Panel -->
<div class="panel" id="panel-settings">
<div class="settings-view">
<label data-l10n="Server">Server</label>
<div class="settings-url-row">
<select id="server-source">
<option value="same" data-l10n="Same origin">Same origin</option>
<option value="custom" data-l10n="Custom URL">Custom URL</option>
</select>
<input type="text" id="server-url" placeholder="http://127.0.0.1:8081" style="display:none;">
<button id="connect-btn" data-l10n="Connect">Connect</button>
</div>
<div class="conn-status disconnected" id="conn-status">
⬤ Not connected — ensure the server is running on the URL above
</div>
<div class="capture-mode" id="capture-mode"></div>
<div class="version-warn" id="version-warn"></div>
<div class="conn-hint" id="conn-hint"></div>
<div class="conn-instructions" id="conn-instructions"></div>
</div>
</div>
<!-- Delete confirmation modal -->
<div class="modal-overlay" id="delete-modal">
<div class="modal">
<h3 data-l10n="Delete Session">Delete Session</h3>
<p><span data-l10n="Delete">Delete</span> <strong id="delete-session-name"></strong> <span data-l10n="and all its APDU data? This cannot be undone.">and all its APDU data? This cannot be undone.</span></p>
<div class="modal-buttons">
<button class="cancel" id="delete-cancel" data-l10n="Cancel">Cancel</button>
<button class="confirm" id="delete-confirm" data-l10n="Delete">Delete</button>
</div>
</div>
</div>
<script>
// ──────────────────── Service worker ────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then(reg => {
console.log('SW registered, scope:', reg.scope);
}).catch(err => {
console.error('SW registration failed:', err);
});
}
// ──────────────────── State ────────────────────
const STATE = {
serverUrl: localStorage.getItem('serverUrl') || '',
activeTab: 'trace',
captureActive: false,
currentSessionId: null,
currentSessionName: null,
sessionViewId: null,
livePoller: null,
messages: [],
typeCounts: [],
traceMap: [],
sideView: 'map',
pendingDeleteId: null,
serverAvailable: false,
serverName: '',
serverVersion: '',
captureMode: '',
deviceConnected: null,
mapExpanded: new Set(),
};
const API_BASE = () => STATE.serverUrl;
const INS_NAMES = {
'a4': 'SELECT', 'a0': 'SELECT', '70': 'MANAGE CHANNEL',
'b0': 'READ BINARY', 'b1': 'READ RECORD',
'd6': 'UPDATE BINARY', 'dc': 'UPDATE RECORD',
'c0': 'GET RESPONSE', 'c2': 'ENVELOPE',
'20': 'VERIFY', '21': 'VERIFY',
'88': 'AUTHENTICATE', '89': 'AUTHENTICATE',
'82': 'EXTERNAL AUTHENTICATE',
'84': 'GET CHALLENGE',
'f2': 'STATUS',
'14': 'TERMINAL RESPONSE',
'12': 'FETCH',
'e2': 'APPEND RECORD',
'32': 'INCREASE',
'04': 'DEACTIVATE FILE',
'44': 'ACTIVATE FILE',
};
// ──────────────────── UI Helpers ────────────────────
function ins_label(msg) {
if (msg.decoded && msg.decoded.cat_command) return msg.decoded.cat_command;
if (msg.decoded && msg.decoded.ins_name) return msg.decoded.ins_name;
if (msg.type === 'atr') return 'ATR';
if (msg.type === 'pps') return 'PPS';
if (msg.type === 'change') return 'STATE CHANGE';
if (msg.type === 'fidi') return 'FIDI';
if (msg.type === 'gap') return 'CAPTURE GAP';
const d = msg.data;
if (d && d.length >= 2) {
const ins = d.slice(2, 4);
return INS_NAMES[ins.toLowerCase()] || '0x' + ins.toUpperCase();
}
return 'TPDU';
}
function _fallback_label(msg) {
if (msg.type === 'atr') return 'ATR';
if (msg.type === 'pps') return 'PPS';
if (msg.type === 'change') return 'STATE CHANGE';
if (msg.type === 'fidi') return 'FIDI';
if (msg.type === 'gap') return 'CAPTURE GAP';
const data = msg.data;
if (data && data.length >= 2) {
const ins = data.slice(2, 4);
return INS_NAMES[ins.toLowerCase()] || '0x' + ins.toUpperCase();
}
return 'TPDU';
}
function stat_label(msg) {
const d = msg.decoded;
if (d) {
if (d.ins_name === 'ENVELOPE') return 'ENVELOPE';
if (d.cat_command) {
if (d.cat_command.startsWith('PROVIDE LOCAL INFORMATION')) return 'PROVIDE LOCAL INFORMATION';
return d.cat_command;
}
if (d.ins_name) return d.ins_name;
}
return _fallback_label(msg);
}
function filter_label(msg) {
const d = msg.decoded;
if (d) {
if (d.ins_name === 'ENVELOPE') return 'ENVELOPE';
if (d.ins_name === 'FETCH' || d.ins_name === 'TERMINAL RESPONSE') return 'FETCH: ANY PROACTIVE';
if (d.ins_name) return d.ins_name;
}
return _fallback_label(msg);
}
function fmt_elapsed(e) {
const s = Math.floor(e);
const ms = Math.round((e - s) * 1000);
return `${String(s).padStart(4, '0')}.${String(ms).padStart(3, '0')}`;
}
function fmt_data(hex, limit) {
limit = limit || 32;
const pairs = (hex || '').match(/.{1,2}/g) || [];
const shown = pairs.slice(0, limit);
let s = shown.join(' ');
if (pairs.length > limit) s += ' ...';
return s.toUpperCase();
}
function hx(n, len) {
len = len || 2;
return n.toString(16).toUpperCase().padStart(len, '0');
}
function fmtSize(bytes) {
if (bytes == null) return '?';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function data_text(m) {
if (m.type === 'change' && m.decoded && Array.isArray(m.decoded.flags)) {
return m.decoded.flags.join(', ');
}
return fmt_data(m.data);
}
function cmd_description(m) {
if (m.type === 'gap') return t('device disconnected — messages may be missing');
if (m.decoded && m.decoded.summary) return m.decoded.summary;
return data_text(m);
}
function error_names(m) {
const d = m.decoded;
return (d && Array.isArray(d.errors) && d.errors.length) ? d.errors : null;
}
function sw_text(m) {
const d = m.decoded;
if (!d || !d.sw) return '';
return (d.sw.sw1 + ' ' + d.sw.sw2).toUpperCase();
}
function sw_class(m) {
const d = m.decoded;
if (!d || !d.sw) return '';
const s = d.sw.sw1;
if (s === '90' || (s >= '92' && s <= '9f')) return 'sw-ok';
if (s === '61' || s === '91' || s === '6c') return 'sw-pending';
if (s === '62' || s === '63') return 'sw-warn';
if (s >= '64' && s <= '6f') return 'sw-error';
return '';
}
// ──────────────────── API ────────────────────
const API = {
async _fetch(method, path, body) {
const opts = { method, headers: {} };
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
try {
const res = await fetch(API_BASE() + path, opts);
return await res.json();
} catch (e) {
STATE.serverAvailable = false;
updateCaptureButton();
throw e;
}
},
status() { return this._fetch('GET', '/api/status'); },
listSessions() { return this._fetch('GET', '/api/sessions'); },
getSession(id, offset) { return this._fetch('GET', `/api/sessions/${id}?offset=${offset || 0}&limit=500`); },
renameSession(id, name) { return this._fetch('PATCH', `/api/sessions/${id}/name`, { name }); },
deleteSession(id) { return this._fetch('DELETE', `/api/sessions/${id}`); },
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}`); },
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 importPcap(name, bytes) {
const res = await fetch(API_BASE() + '/api/sessions/import?name=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: bytes,
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error || 'Import failed'), { status: res.status });
return data;
},
};
// ──────────────────── UI Helpers ────────────────────
function el(id) { return document.getElementById(id); }
function $on(id, ev, fn) { el(id).addEventListener(ev, fn); }
function responseRows(resp) {
if (!resp) return '';
const rows = [];
if (resp.qualifier) rows.push(['Qualifier', resp.qualifier]);
const fd = resp.file_descriptor;
if (fd) {
rows.push(['File type', fd.file_type]);
if (fd.structure) rows.push(['Structure', fd.structure]);
rows.push(['Shareable', fd.shareable]);
if (fd.data_coding) rows.push(['Data coding', fd.data_coding]);
if (fd.record_length != null) rows.push(['Record length', fd.record_length]);
if (fd.num_records != null) rows.push(['Records', fd.num_records]);
}
if (resp.file_id) rows.push(['File ID', resp.file_id + (resp.file_id_name ? ' (' + resp.file_id_name + ')' : '')]);
if (resp.df_name) rows.push(['DF name', resp.df_name]);
if (resp.sfi) rows.push(['SFI', resp.sfi]);
if (resp.life_cycle) rows.push(['Life cycle', resp.life_cycle]);
if (resp.short_ef_id) rows.push(['Short EF ID', resp.short_ef_id]);
if (resp.type === 'GSM' || resp.type === '3G/EPS/5G') {
rows.push(['Auth', resp.type + (resp.status ? ' (' + resp.status + ')' : '')]);
if (resp.sres) rows.push(['SRES', resp.sres]);
if (resp.res) rows.push(['RES', resp.res]);
if (resp.ck) rows.push(['CK', resp.ck]);
if (resp.ik) rows.push(['IK', resp.ik]);
if (resp.kc) rows.push(['Kc', resp.kc]);
if (resp.auts) {
rows.push(['AUTS', resp.auts]);
if (resp.sqn_ak) rows.push(['SQN⊕AK', resp.sqn_ak + ' (concealed)']);
if (resp.mac_s) rows.push(['MAC-S', resp.mac_s]);
}
}
if (resp.code !== undefined) {
rows.push(['Result', resp.code + (resp.name ? ' — ' + resp.name : '')]);
}
if (resp.duration) {
rows.push(['Duration', resp.duration.value + ' ' + resp.duration.unit]);
}
if (resp.item_identifier !== undefined) {
rows.push(['Item', resp.item_identifier]);
}
if (resp.local_info) {
for (const [k, v] of Object.entries(resp.local_info)) {
rows.push([k, v]);
}
}
if (resp.rpl !== undefined) {
rows.push(['Response packet', 'Response Packet Length ' + resp.rpl + ' · Response Header Length ' + resp.rhl]);
rows.push(['TAR', '0x' + resp.tar]);
rows.push(['CNTR', '0x' + resp.cntr]);
rows.push(['PCNTR', resp.pcntr]);
const st = resp.status || {};
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
if (resp.rc_cc_ds) rows.push(['RC/CC/DS', resp.rc_cc_ds]);
if (resp.data) rows.push(['Response data', resp.data]);
}
if (!rows.length) return '';
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">Response</td></tr>` +
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
}
function cmdRows(cmd) {
if (!cmd) return '';
const rows = [];
// AUTHENTICATE command body
if (cmd.rand !== undefined || cmd.autn !== undefined || cmd.specific_key) {
if (cmd.specific_key) rows.push(['Key', 'specific reference data']);
if (cmd.rand) rows.push(['RAND', cmd.rand]);
if (cmd.autn) {
rows.push(['AUTN', cmd.autn]);
if (cmd.sqn_ak) rows.push(['SQN⊕AK', cmd.sqn_ak + ' (concealed)']);
if (cmd.amf) rows.push(['AMF', cmd.amf]);
if (cmd.mac) rows.push(['MAC', cmd.mac]);
}
}
// ENVELOPE fields
if (cmd.events) rows.push(['Event', cmd.events.join(', ')]);
if (cmd.location_status) rows.push(['Location status', cmd.location_status]);
if (cmd.location_info) rows.push(['Location info', cmd.location_info]);
if (cmd.caller) rows.push(['Caller', cmd.caller]);
if (cmd.transaction_id !== undefined) rows.push(['Transaction', cmd.transaction_id]);
if (cmd.subaddress) rows.push(['Subaddress', cmd.subaddress]);
if (cmd.smsc) rows.push(['SMSC', cmd.smsc]);
if (cmd.tp_da) rows.push(['TP-DA', cmd.tp_da]);
if (cmd.device_ids) rows.push(['Device IDs', cmd.device_ids]);
if (cmd.cb_page) {
const cb = cmd.cb_page;
if (cb.serial) rows.push(['CB serial', cb.serial]);
if (cb.message_id) rows.push(['CB message', cb.message_id]);
if (cb.dcs) rows.push(['CB DCS', cb.dcs]);
if (cb.page) rows.push(['CB page', cb.page]);
if (cb.content) rows.push(['CB content', cb.content]);
}
// Proactive command fields (FETCH)
if (cmd.qualifier) rows.push(['Qualifier', cmd.qualifier]);
if (cmd.file_list) rows.push(['File list', cmd.file_list.join(', ')]);
if (cmd.aid) rows.push(['AID', cmd.aid]);
if (cmd.title) rows.push(['Title', cmd.title]);
if (cmd.text !== undefined) rows.push(['Text', cmd.text]);
if (cmd.items) {
for (const it of cmd.items) {
rows.push(['Item ' + (it.id !== undefined ? '0x' + hx(it.id, 2) : '?'), it.text]);
}
}
if (cmd.duration) rows.push(['Duration', cmd.duration.value + ' ' + cmd.duration.unit]);
if (cmd.address) rows.push(['Address', cmd.address]);
if (cmd.response_length) rows.push(['Response length', cmd.response_length.min + '…' + cmd.response_length.max]);
// SM TPDU (nested in SMS-PP download / SEND SHORT MESSAGE)
const t = cmd.tpdu;
if (t) {
rows.push(['SMS TPDU', t.mti]);
if (t.oa) rows.push(['TP-OA', t.oa]);
if (t.da) rows.push(['TP-DA', t.da]);
if (t.mr !== undefined) rows.push(['TP-MR', '0x' + hx(t.mr, 2)]);
if (t.scts) rows.push(['TP-SCTS', t.scts]);
if (t.pid !== undefined) rows.push(['TP-PID', '0x' + hx(t.pid, 2) + (t.pid_name ? ' — ' + t.pid_name : '')]);
if (t.dcs !== undefined) {
const di = t.dcs_info || {};
let dcsTxt = '0x' + hx(t.dcs, 2) + ' — ' + t.encoding;
if (di.group) dcsTxt += ' · ' + di.group;
if (di.compressed) dcsTxt += ' · compressed';
if (di.indication) dcsTxt += ' · ' + di.indication + ' (' + di.sense + ')';
else if (t.msg_class !== undefined && di.has_class) dcsTxt += ' · class ' + t.msg_class;
rows.push(['TP-DCS', dcsTxt]);
}
if (t.vp) rows.push(['TP-VP', t.vp]);
if (Array.isArray(t.udh)) {
for (const ie of t.udh) {
let v = ie.name || ('IEI ' + ie.iei);
const d = ie.data;
if (d) {
if (d.seq !== undefined && d.max !== undefined)
v += ` — ref ${d.reference}, part ${d.seq}/${d.max}`;
else if (d.dest_port !== undefined)
v += ` — dst ${d.dest_port}, src ${d.orig_port}`;
else if (d.count !== undefined)
v += ` — ${d.indication || 'messages'}: ${d.count}` + (d.store ? ' (store)' : '');
}
if (ie.hex) v += ' · ' + ie.hex;
rows.push(['UDH', v]);
}
}
if (t.text !== undefined) rows.push(['TP-UD', t.text]);
if (t.payload) rows.push(['TP-UD (payload)', t.payload]);
const sec = t.secured;
if (sec && !sec.raw) {
rows.push(['Secured packet', 'Command Packet Length ' + sec.cpl + ' · Command Header Length ' + sec.chl]);
const s = sec.spi || {};
rows.push(['SPI', '0x' + (s.hex || '') + ' — ' + (s.counter || '') +
(s.ciphering ? ', ciphering' : '') + ', ' + (s.rc_cc_ds || '') + ', ' + (s.por || '')]);
rows.push(['KIc', 'key ' + sec.kic.key + ' — ' + sec.kic.algo]);
rows.push(['KID', 'key ' + sec.kid.key + ' — ' + sec.kid.algo]);
rows.push(['TAR', '0x' + sec.tar]);
rows.push(['CNTR', '0x' + sec.cntr]);
rows.push(['PCNTR', sec.pcntr]);
if (sec.rc_cc_ds) rows.push(['RC/CC/DS', sec.rc_cc_ds]);
rows.push(['Secured data', sec.data]);
}
const rp = t.response_packet;
if (rp && !rp.raw) {
rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]);
rows.push(['TAR', '0x' + rp.tar]);
rows.push(['CNTR', '0x' + rp.cntr]);
rows.push(['PCNTR', rp.pcntr]);
const st = rp.status || {};
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
if (rp.rc_cc_ds) rows.push(['RC/CC/DS', rp.rc_cc_ds]);
rows.push(['Response data', rp.data]);
}
}
if (!rows.length) return '';
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">Command</td></tr>` +
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
}
function fileRows(f) {
if (!f) return '';
const title = f.ef ? `${f.ef} (0x${(f.fid || '').toUpperCase()})` : (f.fid || '').toUpperCase();
const rows = [];
if (f.unknown) {
rows.push(['File', `${f.ef || ('SFI ' + f.sfi)} — unresolved SFI reference`]);
} else if (f.stale) {
rows.push(['File', title + ' — selection stale (record op on non-record file)']);
} else if (f.empty) {
rows.push(['File', title + ' — empty']);
} else {
rows.push(['File', title]);
if (f.imsi) rows.push(['IMSI', f.imsi]);
if (f.iccid) rows.push(['ICCID', f.iccid]);
if (f.languages) rows.push(['Languages', f.languages.join(', ')]);
if (f.name !== undefined) rows.push(['Name', f.name]);
if (f.full !== undefined) rows.push(['Full name', f.full]);
if (f.short !== undefined) rows.push(['Short name', f.short]);
if (f.number) rows.push(['Number', f.number]);
if (f.text) rows.push(['Text', f.text]);
if (f.ccp !== undefined) rows.push(['CCP', '0x' + hx(f.ccp, 2)]);
if (f.ext1 !== undefined) rows.push(['Ext1', '0x' + hx(f.ext1, 2)]);
if (f.plmns) rows.push(['PLMNs', f.plmns.map(p => p.mcc + '/' + p.mnc + (p.access_tech ? ' (' + p.access_tech + ')' : '')).join(', ')]);
if (f.services) {
for (const s of f.services) rows.push(['Service ' + s.n, s.name || '']);
}
if (f.applications) {
for (const a of f.applications) {
rows.push(['App', (a.label ? a.label + ' · ' : '') + (a.aid || '')]);
if (a.eap) {
const types = (a.eap.eap_types || []).join(', ');
const dfs = (a.eap.dfs || []).map(d => d.toUpperCase()).join(', ');
rows.push(['EAP', `${a.eap.label || 'EAP'} (${types}) in ${dfs}`]);
}
}
}
if (f.direction) rows.push(['SMS', f.direction + ' — ' + f.status]);
if (f.message_ids) rows.push(['Message IDs', f.message_ids.join(', ')]);
if (f.record_numbers) rows.push(['Records found', f.record_numbers.join(', ')]);
if (f.range) rows.push(['Range', f.range[0] + '…' + f.range[1]]);
if (f.access_classes) rows.push(['Access classes', f.access_classes.join(', ')]);
if (f.phase) rows.push(['Phase', f.phase]);
if (f.display_condition) rows.push(['Display condition', f.display_condition]);
if (f.mcc) {
const parts = ['MCC ' + f.mcc + ' MNC ' + f.mnc];
if (f.lac) parts.push('LAC ' + f.lac);
if (f.tac) parts.push('TAC ' + f.tac);
if (f.rac) parts.push('RAC ' + f.rac);
rows.push(['Location', parts.join(' · ')]);
}
if (f.tmsi) rows.push(['TMSI', f.tmsi]);
if (f.tmsi_time) rows.push(['TMSI time', f.tmsi_time]);
if (f.location_update_status) rows.push(['Update status', f.location_update_status]);
if (f.guti) rows.push(['GUTI', f.guti]);
if (f.eps_update_status) rows.push(['EPS update status', f.eps_update_status]);
if (f.p_tmsi) rows.push(['P-TMSI', f.p_tmsi]);
if (f.p_tmsi_signature) rows.push(['P-TMSI signature', f.p_tmsi_signature]);
if (f.update_status) rows.push(['Update status', f.update_status]);
if (f.ksi_asme) rows.push(['KSI (ASME)', f.ksi_asme]);
if (f.k_asme) rows.push(['K_ASME', f.k_asme]);
if (f.uplink_nas_count !== undefined) rows.push(['UpLink NAS count', f.uplink_nas_count]);
if (f.downlink_nas_count !== undefined) rows.push(['DownLink NAS count', f.downlink_nas_count]);
if (f.nas_algorithms) rows.push(['NAS algorithms', f.nas_algorithms]);
if (f.rules) rows.push(['Rules', f.rules.map(r => r.tag + ' ' + r.hex).join(', ')]);
if (f.raw) rows.push(['Data', f.raw]);
if (f.tpdu) {
const t = f.tpdu;
rows.push(['SMS TPDU', t.mti]);
if (t.oa) rows.push(['TP-OA', t.oa]);
if (t.da) rows.push(['TP-DA', t.da]);
if (t.text !== undefined) rows.push(['TP-UD', t.text]);
if (t.payload) rows.push(['TP-UD (payload)', t.payload]);
}
}
if (!rows.length) return '';
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">File data</td></tr>` +
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
}
function ifaceValue(ifc) {
const v = [];
if (ifc.f !== undefined) v.push('F=' + ifc.f);
if (ifc.d !== undefined) v.push('D=' + ifc.d);
if (ifc.f_div_d !== undefined) v.push('F/D=' + ifc.f_div_d);
if (ifc.f_max !== undefined) v.push('f(max)=' + ifc.f_max + ' MHz');
if (ifc.programming !== undefined) v.push(ifc.programming);
if (ifc.extra_guard_time !== undefined) v.push('N=' + ifc.extra_guard_time + ' etu');
if (ifc.work_waiting_time !== undefined) v.push('WI=' + ifc.work_waiting_time);
if (ifc.specific !== undefined) v.push(ifc.specific ? 'specific mode' : 'negotiable mode');
if (ifc.protocol !== undefined) v.push(ifc.protocol);
if (ifc.ifsc !== undefined) v.push('IFSC=' + ifc.ifsc);
if (ifc.bwi !== undefined) v.push('BWI=' + ifc.bwi + ', CWI=' + ifc.cwi);
if (ifc.clock_stop) v.push(ifc.clock_stop);
if (ifc.classes && ifc.classes.length) v.push('classes ' + ifc.classes.join(', '));
if (ifc.spu !== undefined) v.push('SPU ' + ifc.spu);
return (v.length ? v.join(' · ') : '') + (ifc.hex ? ' (0x' + ifc.hex + ')' : '');
}
function atrPpsRows(decoded) {
const rows = [];
if (decoded.type === 'atr') {
rows.push(['Convention', decoded.convention || 'unknown']);
if (decoded.ts) rows.push(['TS', '0x' + decoded.ts]);
if (decoded.t0) rows.push(['T0', '0x' + decoded.t0 + ' — ' + decoded.historical_len + ' historical bytes']);
if (decoded.protocols) rows.push(['Protocols', decoded.protocols.join(', ')]);
if (decoded.interface) {
for (const ifc of decoded.interface) {
rows.push([ifc.name, ifaceValue(ifc)]);
}
}
if (decoded.historical) {
const h = decoded.historical;
let v = h.category || '';
if (h.life_cycle) v += ' — ' + h.life_cycle;
if (h.sw) v += ' (SW ' + h.sw + ')';
rows.push(['Historical', v + ' · ' + h.raw]);
}
if (decoded.tck) {
rows.push(['TCK', '0x' + decoded.tck + (decoded.tck_valid ? ' (valid)' : ' (mismatch)')]);
}
} else {
if (decoded.ppss) rows.push(['PPSS', '0x' + decoded.ppss]);
if (decoded.pps0) rows.push(['PPS0', '0x' + decoded.pps0]);
if (decoded.protocol) rows.push(['Protocol', decoded.protocol]);
if (decoded.fi_di) {
const fd = decoded.fi_di;
const v = [];
if (fd.f !== undefined) v.push('F=' + fd.f);
if (fd.d !== undefined) v.push('D=' + fd.d);
if (fd.f_div_d !== undefined) v.push('F/D=' + fd.f_div_d);
rows.push(['PPS1', v.join(' · ') + ' (0x' + fd.pps1 + ')']);
}
if (decoded.pps2 !== undefined) rows.push(['PPS2', '0x' + decoded.pps2 + (decoded.spu ? ' — ' + decoded.spu : '')]);
if (decoded.pck) rows.push(['PCK', '0x' + decoded.pck + (decoded.pck_valid ? ' (valid)' : ' (mismatch)')]);
if (decoded.note) rows.push(['Note', decoded.note]);
}
rows.push(['Raw', (decoded.raw || '')]);
if (decoded.errors) rows.push(['Errors', decoded.errors.join(', ')]);
return rows;
}
function showDetail(msg, entry) {
// Highlight clicked entry
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
if (entry) entry.classList.add('selected');
const d = el('apdu-detail');
const decoded = msg.decoded;
if (msg.type === 'gap') {
d.innerHTML = `<div class="detail-header"><strong>Capture Gap</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
<div class="detail-body" style="color:var(--warn);">⚠ ${t('The device disconnected and reconnected — some APDUs may be missing from this trace.')}</div>`;
return;
}
if (!decoded) {
d.innerHTML = `<div class="detail-header"><strong>${ins_label(msg)}</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
<div class="detail-body" style="color:var(--text-muted);">No decoded data available for this message type.</div>`;
return;
}
if (decoded.type === 'change') {
d.innerHTML = `<div class="detail-header"><strong>State Change</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
<div class="detail-body">
<table>
<tr><td class="label">Flags</td><td class="value">${decoded.flags.join(', ') || 'no changes'}</td></tr>
</table>
</div>`;
return;
}
if (decoded.type === 'fidi') {
d.innerHTML = `<div class="detail-header"><strong>Fi/Di Change</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
<div class="detail-body">
<table>
<tr><td class="label">Fi</td><td class="value">${decoded.fi_val} (index ${decoded.fi})</td></tr>
<tr><td class="label">Di</td><td class="value">${decoded.di_val} (index ${decoded.di})</td></tr>
<tr><td class="label">Raw</td><td class="hex">0x${(decoded.fidi || '').toUpperCase()}</td></tr>
</table>
</div>`;
return;
}
if (decoded.type === 'atr' || decoded.type === 'pps') {
const rows = atrPpsRows(decoded);
d.innerHTML = `<div class="detail-header"><strong>${decoded.type.toUpperCase()}</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
<div class="detail-body">
<table>
${rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="hex">${v}</td></tr>`).join('')}
</table>
</div>`;
return;
}
let body = '';
if (decoded.body) {
const note = decoded.body.note ? ` <span style="color:var(--accent);">${decoded.body.note}</span>` : '';
body = `<tr><td class="label">${decoded.body.label || 'Body'}</td><td class="hex">${(decoded.body.hex || '').toUpperCase()}${note}</td></tr>`;
}
let p1p2 = '';
if (decoded.p1p2) {
if (decoded.p1p2.unused) {
p1p2 = `<tr><td class="label">P1, P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} (RFU)</td></tr>`;
} else if (decoded.p1p2.sfi !== undefined) {
p1p2 = `<tr><td class="label">P1-P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} — SFI ${decoded.p1p2.sfi}, offset ${decoded.p1p2.offset}</td></tr>`;
} else if (decoded.p1p2.offset !== undefined) {
p1p2 = `<tr><td class="label">P1-P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} — Offset: ${decoded.p1p2.offset}</td></tr>`;
} else {
p1p2 = `<tr><td class="label">P1-P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} — ${decoded.p1p2.label}: ${decoded.p1p2.value}</td></tr>`;
}
}
let p1 = '';
if (decoded.p1) {
const n = decoded.p1.name || (decoded.p1.label && decoded.p1.value != null ? `${decoded.p1.label}: ${decoded.p1.value}` : '');
p1 = `<tr><td class="label">P1</td><td class="value">0x${(decoded.p1.raw || '').toUpperCase()}${n ? ` — <span class="name">${n}</span>` : ''}</td></tr>`;
}
let p2 = '';
if (decoded.p2) {
const parts = [];
if (decoded.p2.sfi) parts.push('SFI ' + decoded.p2.sfi);
const m = decoded.p2.name || (decoded.p2.bits ? decoded.p2.bits.join(', ') : '');
if (m) parts.push(m);
p2 = `<tr><td class="label">P2</td><td class="value">0x${(decoded.p2.raw || '').toUpperCase()}${parts.length ? ` — <span class="name">${parts.join(', ')}</span>` : ''}</td></tr>`;
}
let sw = '';
if (decoded.sw) {
const sn = decoded.sw.name || '';
sw = `<tr><td class="label">SW</td><td class="value">${(decoded.sw.sw1 || '').toUpperCase()} ${(decoded.sw.sw2 || '').toUpperCase()} <span style="color:${sn ? 'var(--accent)' : 'var(--text-muted)'}">${sn}</span></td></tr>`;
}
const cla = decoded.cla;
const claInfo = `${cla.interclass}, ch${cla.channel}, SM: ${cla.secure_messaging}, chain: ${cla.chain}`;
const catRow = decoded.cat_command
? `<tr><td class="label">CAT</td><td class="name">${decoded.cat_command}</td></tr>`
: '';
const respRow = decoded.response_for !== undefined
? `<tr><td class="label">Response for</td><td class="name">${decoded.response_for || 'orphaned'}</td></tr>`
: '';
const trRow = decoded.response_to
? `<tr><td class="label">Responding to</td><td class="name">${decoded.response_to}</td></tr>`
: '';
const lm = decoded.length_mismatch;
const lmNote = lm
? ` <span style="color:var(--error)">— ${lm.kind} (${lm.actual}/${lm.expected} bytes)</span>`
: '';
d.innerHTML = `<div class="detail-header">
<strong>${decoded.cat_command || decoded.ins_name}</strong>
<span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span>
</div>
<div class="detail-body">
<table>
<tr><td class="label">Raw APDU</td><td class="hex">${fmt_data(msg.data, 9999)}</td></tr>
<tr><td class="label">CLA</td><td class="value">0x${(cla.hex || '').toUpperCase()} — ${claInfo}</td></tr>
<tr><td class="label">INS</td><td class="name">0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name}</td></tr>
${catRow}${respRow}${trRow}
${p1p2}${p1}${p2}
<tr><td class="label">P3</td><td class="value">0x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${lmNote}</td></tr>
${body}
${cmdRows(decoded.cmd)}
${responseRows(decoded.response)}
${fileRows(decoded.file)}
${sw}
${decoded.errors ? `<tr><td class="label">Errors</td><td class="value" style="color:var(--error)">${decoded.errors.join(', ')}</td></tr>` : ''}
</table>
</div>`;
}
function buildTypeCounts() {
const counts = {};
for (const m of STATE.messages) {
const lbl = stat_label(m);
counts[lbl] = (counts[lbl] || 0) + 1;
}
STATE.typeCounts = counts;
}
function _ins_hex(m) {
if (m.type !== 'tpdu' || !m.decoded) return null;
return m.decoded.ins_hex;
}
function map_file_label(m) {
const d = m.decoded;
if (!d) return null;
if (d.ins_hex === 'a4') { // SELECT
if (d.body && d.body.note) return d.body.note.replace('current ADF', 'ADF');
if (d.summary) return d.summary.replace('current ADF', 'ADF');
return null;
}
if (d.file) return d.file.ef || d.file.fid || null;
return null;
}
const MAP_RW_INS = new Set(['a4', 'a2', 'b0', 'b1', 'b2', 'd6', 'dc']);
function is_read_ins(ins) { return ins === 'a2' || ins === 'b0' || ins === 'b1' || ins === 'b2'; }
function is_write_ins(ins) { return ins === 'd6' || ins === 'dc'; }
function map_other_label(m) {
const d = m.decoded;
if (d && d.ins_name === 'ENVELOPE' && d.summary) {
return 'ENVELOPE: ' + d.summary;
}
return stat_label(m);
}
// Map-view styling category for a single-message group.
function map_item_cat(ins) {
if (ins === '12') return 'proactive'; // FETCH
if (ins === '14' || ins === '10' || ins === 'aa') return 'tr'; // TERMINAL RESPONSE / PROFILE / CAPABILITY
if (ins === 'c2') return 'envelope'; // ENVELOPE
if (ins === '88' || ins === '89') return 'auth'; // AUTHENTICATE
return 'other';
}
function buildTraceMap() {
const msgs = STATE.messages;
const groups = [];
let i = 0;
while (i < msgs.length) {
const ins = _ins_hex(msgs[i]);
if (ins === 'c0') { i++; continue; } // skip GET RESPONSE
if (ins && MAP_RW_INS.has(ins)) {
// Split the run into per-file records (delimited by SELECT).
// Each READ/UPDATE op contributes its own resolved file, so
// SFI-addressed files (never SELECTed) are attributed correctly.
const access = [];
let cur = null;
let j = i;
while (j < msgs.length) {
const jins = _ins_hex(msgs[j]);
if (jins === 'c0') { j++; continue; } // skip GET RESPONSE
if (!jins || !MAP_RW_INS.has(jins)) break;
if (jins === 'a4') {
if (cur) access.push(cur);
cur = { files: [], selNote: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
} else {
if (!cur) cur = { files: [], selNote: null, hasRead: false, hasWrite: false, start: j, end: j };
const f = map_file_label(msgs[j]);
if (f && !cur.files.includes(f)) cur.files.push(f);
if (is_write_ins(jins)) cur.hasWrite = true; else cur.hasRead = true;
cur.end = j;
}
j++;
}
if (cur) access.push(cur);
// Records with no read/write ops (pure catalog SELECT) fall back to
// the selected file's note.
for (const rec of access) {
if (rec.files.length === 0 && rec.selNote) rec.files = [rec.selNote];
}
let k = 0;
while (k < access.length) {
const rec = access[k];
if (rec.hasRead && rec.hasWrite) {
groups.push({ kind: 'rw', cat: 'file', files: rec.files, start: rec.start, end: rec.end });
k++;
} else if (rec.hasWrite) {
const files = [];
let end = rec.end;
let m = k;
while (m < access.length && access[m].hasWrite && !access[m].hasRead) {
for (const f of access[m].files) if (f && !files.includes(f)) files.push(f);
end = access[m].end;
m++;
}
groups.push({ kind: 'write', cat: 'file', files, start: rec.start, end });
k = m;
} else {
const files = [];
let end = rec.end;
let m = k;
while (m < access.length && !access[m].hasWrite) {
for (const f of access[m].files) if (f && !files.includes(f)) files.push(f);
end = access[m].end;
m++;
}
groups.push({ kind: 'read', cat: 'file', files, start: rec.start, end });
k = m;
}
}
i = j;
} else if (ins === 'f2') {
let j = i;
while (j < msgs.length && _ins_hex(msgs[j]) === 'f2') j++;
groups.push({ label: 'STATUS: ' + (j - i), cat: 'status', start: i, end: j - 1 });
i = j;
} else {
groups.push({ label: map_other_label(msgs[i]), cat: map_item_cat(ins), start: i, end: i });
i++;
}
}
STATE.traceMap = groups;
}
function renderSidePanel() {
const entries = Object.entries(STATE.typeCounts).sort((a, b) => b[1] - a[1]);
el('type-counts').innerHTML = entries.map(([k, v], i) =>
`<div class="side-item" data-idx="${i}" data-key="${k}">
<span>${k}</span><span class="count">${v}</span>
</div>`
).join('') || t('No commands');
const mapEl = el('type-map');
mapEl.innerHTML = STATE.traceMap.map(g => {
const cls = 'map-item' + (g.cat ? ' map-' + g.cat : '');
if (g.kind) {
const kind = g.kind === 'rw' ? t('Read/write:') : g.kind === 'write' ? t('Writes:') : t('Reads:');
return `<div class="${cls}" data-start="${g.start}">${kind} ${mapFilesHtml(g)}</div>`;
}
return `<div class="${cls}" data-start="${g.start}">${g.label}</div>`;
}).join('') || t('No commands');
const showMap = STATE.sideView === 'map';
el('type-counts').style.display = showMap ? 'none' : '';
mapEl.style.display = showMap ? '' : 'none';
document.querySelectorAll('#side-panel .side-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === STATE.sideView);
});
}
function mapFilesHtml(g) {
const files = g.files || [];
if (files.length === 0) return '—';
if (files.length <= 3) return files.join(', ');
const expanded = STATE.mapExpanded.has(g.start);
const label = expanded ? files.join(', ') : t('{n} files').replace('{n}', files.length);
return `<span class="map-files-toggle" data-start="${g.start}">${label} <span class="map-arrow">${expanded ? '▾' : '▸'}</span></span>`;
}
function populateFilterOptions() {
const sel = el('filter-select');
const counts = {};
for (const m of STATE.messages) {
const lbl = filter_label(m);
counts[lbl] = (counts[lbl] || 0) + 1;
}
const keys = Object.keys(counts).sort((a, b) => a.localeCompare(b));
const existing = Array.from(sel.options).slice(1).map(o => o.value);
if (existing.length === keys.length && existing.every((k, i) => k === keys[i])) return;
const cur = sel.value;
sel.innerHTML = '<option value="">' + t('All commands') + '</option>' +
keys.map(k => `<option value="${escapeHtml(k).replace(/"/g, '&quot;')}">${escapeHtml(k)}</option>`).join('');
sel.value = keys.includes(cur) ? cur : '';
}
function scrollToType(typeKey) {
const apduList = el('apdu-list');
let targetEl = null;
for (const el of apduList.querySelectorAll('.apdu-entry')) {
const msgIdx = parseInt(el.dataset.idx);
const msg = STATE.messages[msgIdx];
if (msg && stat_label(msg) === typeKey) {
targetEl = el;
break;
}
}
if (targetEl) {
targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
targetEl.classList.add('selected');
setTimeout(() => targetEl.classList.remove('selected'), 1500);
}
}
function focusMessage(idx) {
const filterEl = el('filter-select');
const searchEl = el('search-input');
const needRender = filterEl.value !== '' || searchEl.value !== '';
filterEl.value = '';
searchEl.value = '';
if (needRender) renderMessages(true);
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
if (!entry) return;
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
entry.classList.add('selected');
setTimeout(() => entry.classList.remove('selected'), 1500);
showDetail(STATE.messages[idx], entry);
}
// ──────────────────── Render Trace ────────────────────
function isOriginalCommand(i) {
if (STATE.messages[i].type !== 'tpdu') return false;
for (let j = i + 1; j < STATE.messages.length; j++) {
if (STATE.messages[j].type !== 'tpdu') continue;
const d = STATE.messages[j].decoded;
return !!d && d.ins_name === 'GET RESPONSE' && !!d.response_for;
}
return false;
}
function matchesFilter(m, i, filterType) {
if (!filterType) return true;
if (filter_label(m) === filterType) return true;
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;
return false;
}
function renderTraceEmptyState() {
if (STATE.captureActive || STATE.sessionViewId) return;
const list = el('apdu-list');
const connCls = STATE.serverAvailable ? 'connected' : 'disconnected';
const modeText = captureModeText();
const modeHtml = modeText
? `<div class="capture-mode ${STATE.captureMode}">${modeText}</div>`
: '';
const action = STATE.captureMode === 'disabled'
? `<p style="font-size:12px;">${t('Open a saved session from the Sessions tab.')}</p>`
: `<button id="trace-start-btn">${t('Start Capture')}</button>`;
list.innerHTML = `<div class="empty-state">
<div class="conn-status ${connCls}">${connectionStatusText()}</div>
${modeHtml}
${action}
</div>`;
const btn = $('#trace-start-btn', list);
if (btn) btn.addEventListener('click', startCapture);
updateStatus();
}
function renderMessages(skipScroll) {
const list = el('apdu-list');
const searchQ = el('search-input').value.toLowerCase();
const filterType = el('filter-select').value;
const filtered = [];
for (let i = 0; i < STATE.messages.length; i++) {
const m = STATE.messages[i];
if (!matchesFilter(m, i, filterType)) continue;
if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue;
filtered.push({ m, idx: i });
}
buildTypeCounts();
buildTraceMap();
renderSidePanel();
populateFilterOptions();
if (!STATE.captureActive && !STATE.sessionViewId) {
renderTraceEmptyState();
return;
}
list.innerHTML = filtered.map(({ m, idx }) => {
if (m.type === 'gap') {
return `<div class="apdu-entry apdu-gap" data-idx="${idx}">
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
<span class="apdu-type">⚠ CAPTURE GAP</span>
<span class="apdu-data">${cmd_description(m)}</span>
</div>`;
}
const label = ins_label(m);
const typeCls = m.type === 'atr' ? 'atr' : m.type === 'pps' ? 'pps' : '';
const errs = error_names(m);
const errBadge = errs ? `<span class="apdu-err" title="${errs.join(', ')}">⚠</span>` : '';
return `<div class="apdu-entry" data-idx="${idx}">
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
<span class="apdu-type ${typeCls}">${label}</span>
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
<span class="apdu-data">${cmd_description(m)}</span>
${errBadge}
</div>`;
}).join('');
if (!skipScroll) list.scrollTop = list.scrollHeight;
updateStatus();
}
function $(selector, root) { return (root || document).querySelector(selector); }
function updateStatus() {
const dot = el('status-dot');
const text = el('status-text');
const count = el('msg-count');
dot.className = 'status-dot';
if (STATE.captureActive) {
dot.classList.add('live');
text.textContent = t('Live');
} else if (STATE.sessionViewId || STATE.currentSessionId) {
dot.classList.add('paused');
text.textContent = STATE.currentSessionName || t('Session');
} else {
dot.classList.add('off');
text.textContent = t('Not connected');
}
count.textContent = t('{n} messages').replace('{n}', STATE.messages.length);
updateDeviceWarn();
updateCaptureChip();
}
function updateCaptureChip() {
const chip = el('capture-chip');
if (!chip) return;
const disconnected = STATE.captureMode === 'direct' && STATE.captureActive && STATE.deviceConnected === false;
if (disconnected) {
chip.style.display = '';
chip.className = 'capture-chip warn';
chip.textContent = '⚠ ' + t('device disconnected — reconnecting…');
} else if (STATE.captureActive) {
chip.style.display = '';
chip.className = 'capture-chip live';
chip.textContent = '● ' + t('LIVE') + ' · ' + t('{n} messages').replace('{n}', STATE.messages.length);
} else {
chip.style.display = 'none';
}
}
function updateDeviceWarn() {
const warn = el('device-warn');
if (!warn) return;
const disconnected = STATE.captureMode === 'direct' && STATE.captureActive && STATE.deviceConnected === false;
if (disconnected) {
warn.style.display = '';
el('device-warn-text').textContent = t('device disconnected — reconnecting…');
} else {
warn.style.display = 'none';
}
}
// ──────────────────── Live Polling ────────────────────
let _lastAfter = 0;
function startPolling() {
if (STATE.livePoller) return;
STATE.livePoller = setInterval(async () => {
try {
const data = await API.getLatest(_lastAfter);
STATE.deviceConnected = data.device_connected;
if (data.active) {
STATE.captureActive = true;
STATE.currentSessionId = data.session_id;
}
if (data.messages && data.messages.length > 0) {
STATE.messages.push(...data.messages);
_lastAfter = data.next_after;
renderMessages();
}
if (!data.active && STATE.captureActive) {
STATE.captureActive = false;
renderMessages();
}
} catch (e) {}
}, 500);
}
function stopPolling() {
if (STATE.livePoller) {
clearInterval(STATE.livePoller);
STATE.livePoller = null;
}
}
// ──────────────────── Capture ────────────────────
async function startCapture() {
if (STATE.captureMode === 'disabled') return;
try {
const data = await API.startCapture();
STATE.captureActive = true;
STATE.currentSessionId = data.session_id;
STATE.messages = [];
_lastAfter = 0;
STATE.mapExpanded.clear();
buildTypeCounts();
buildTraceMap();
renderSidePanel();
renderMessages();
updateCaptureButton();
startPolling();
} catch (e) {
alert(t('Failed to start capture: ') + e.message);
}
}
async function stopCapture() {
try {
await API.stopCapture();
STATE.captureActive = false;
STATE.currentSessionId = null;
STATE.sessionViewId = null;
updateCaptureButton();
updateStatus();
} catch (e) {}
}
function updateCaptureButton() {
const btn = el('capture-btn');
const newCapture = el('new-capture-btn');
btn.classList.remove('stop', 'connect');
const captureOff = STATE.captureMode === 'disabled' && STATE.serverAvailable;
newCapture.style.display = captureOff ? 'none' : '';
if (captureOff) {
btn.style.display = 'none';
return;
}
if (STATE.sessionViewId && !STATE.captureActive) {
btn.style.display = 'none';
return;
}
btn.style.display = '';
if (!STATE.serverAvailable) {
btn.textContent = t('Connect to server');
btn.classList.add('connect');
} else if (STATE.captureActive) {
btn.textContent = t('Stop Capture');
btn.classList.add('stop');
} else {
btn.textContent = t('Start Capture');
}
}
// ──────────────────── Sessions ────────────────────
async function loadSessions() {
try {
const data = await API.listSessions();
const grid = el('sessions-grid');
const total = data.total_sessions != null ? data.total_sessions : data.sessions.length;
const dbSize = data.db_size != null ? fmtSize(data.db_size) : '?';
const sessionsLabel = total === 1
? t('{n} session · {size}').replace('{n}', total).replace('{size}', dbSize)
: t('{n} session(s) · {size}').replace('{n}', total).replace('{size}', dbSize);
el('sessions-stats').textContent = sessionsLabel;
if (!data.sessions.length) {
grid.innerHTML = `<div class="empty-state" style="height:auto; padding:40px;">
<p>${t('No saved sessions')}</p>
</div>`;
return;
}
grid.innerHTML = data.sessions.map(s => {
const name = s.name || t('Session #') + s.id;
const dStart = s.started ? s.started.replace('T', ' ') : '?';
const duration = s.ended ? formatDuration(s.started, s.ended) : t('active');
return `<div class="session-card" data-id="${s.id}">
<div class="session-name">
<input type="text" value="${escapeHtml(name)}" data-sid="${s.id}" placeholder="${t('Session name...')}">
</div>
<div class="session-meta">
<span>${dStart}</span>
<span>${duration}</span>
<span>${s.message_count} ${t('msgs')}</span>
<span>${s.mode}</span>
</div>
<div class="session-actions">
<a class="session-download" href="${API_BASE()}/api/sessions/${s.id}/pcap">${t('Download PCAP')}</a>
<button class="session-delete" data-sid="${s.id}">${t('Delete')}</button>
</div>
</div>`;
}).join('');
grid.querySelectorAll('.session-card').forEach(card => {
card.addEventListener('click', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'A') return;
loadSession(parseInt(card.dataset.id));
});
});
grid.querySelectorAll('.session-name input').forEach(input => {
const originalName = input.value;
input.addEventListener('blur', async () => {
const sid = parseInt(input.dataset.sid);
const name = input.value.trim();
try {
await API.renameSession(sid, name);
} catch (e) {
input.value = originalName;
}
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') input.blur();
});
});
grid.querySelectorAll('.session-delete').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sid = parseInt(btn.dataset.sid);
showDeleteModal(sid);
});
});
} catch (e) {}
}
async function importPcapFile(file) {
const msg = el('import-msg');
msg.classList.remove('error');
msg.textContent = t('Importing…');
try {
const buf = await file.arrayBuffer();
const baseName = file.name.replace(/\.[^.]+$/, '') || file.name;
const data = await API.importPcap(baseName, buf);
msg.textContent = t('Imported \'{name}\' — {n} messages')
.replace('{name}', data.name).replace('{n}', data.message_count);
loadSessions();
} catch (e) {
msg.classList.add('error');
msg.textContent = t((e && e.message) || 'Import failed');
}
}
async function loadSession(id) {
if (STATE.captureActive && !confirm(t('Opening a session stops the active capture. Continue?'))) return;
try {
// Stop any active capture so it can't write into the session being viewed.
await API.stopCapture();
STATE.captureActive = false;
STATE.currentSessionId = null;
stopPolling();
const data = await API.getSession(id, 0);
STATE.sessionViewId = id;
STATE.captureActive = false;
STATE.currentSessionName = data.session.name || t('Session #') + id;
STATE.messages = data.messages;
STATE.mapExpanded.clear();
switchTab('trace');
updateCaptureButton();
renderMessages();
el('session-name-display').style.display = 'inline';
el('session-name-display').textContent = t('Session: ') + (data.session.name || '#' + id);
} catch (e) {
alert(t('Failed to load session'));
}
}
function formatDuration(start, end) {
if (!start || !end) return '?';
const ms = new Date(end) - new Date(start);
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function showDeleteModal(id) {
STATE.pendingDeleteId = id;
const s = document.querySelector(`.session-card[data-id="${id}"] input`);
el('delete-session-name').textContent = s ? s.value : t('Session #') + id;
el('delete-modal').classList.add('show');
}
async function confirmDelete() {
const id = STATE.pendingDeleteId;
if (!id) return;
try {
await API.deleteSession(id);
el('delete-modal').classList.remove('show');
STATE.pendingDeleteId = null;
loadSessions();
} catch (e) {
alert(t('Failed to delete session'));
}
}
// ──────────────────── Settings ────────────────────
let _connTimer = null;
function targetSpace() {
let h;
try { h = new URL(STATE.serverUrl).hostname.toLowerCase(); } catch (e) { return 'public'; }
if (h === 'localhost' || h === '[::1]' || h === '::1' || /^127\./.test(h)) return 'loopback';
if (/^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 'private';
return 'public';
}
function isLikelyBlocked() {
const pagePublic = location.protocol === 'https:'
|| !(location.hostname === 'localhost' || /^127\./.test(location.hostname));
const s = targetSpace();
return pagePublic && (s === 'loopback' || s === 'private');
}
function syncServerSourceUi() {
const source = el('server-source');
const urlInput = el('server-url');
const custom = STATE.serverUrl !== '';
source.value = custom ? 'custom' : 'same';
urlInput.style.display = custom ? '' : 'none';
if (custom) urlInput.value = STATE.serverUrl;
}
function updateVersionWarn(serverVersion) {
const elv = el('version-warn');
const sv = parseVersion(serverVersion);
if (sv === null) {
elv.textContent = t('Server version unknown — update simtrace2-pysniff-server.');
return;
}
const cmpMin = compareVersions(serverVersion, MIN_SERVER_VERSION);
if (cmpMin !== null && cmpMin < 0) {
elv.textContent = t('Server version {v} is outdated. Please update simtrace2-pysniff-server.').replace('{v}', serverVersion);
return;
}
if (sv[0] > parseVersion(PWA_VERSION)[0]) {
elv.textContent = t('Server version {v} is newer than expected. Update simtrace2-pysniff for full compatibility.').replace('{v}', serverVersion);
return;
}
elv.textContent = '';
}
function captureModeText() {
switch (STATE.captureMode) {
case 'gsmtap':
return t('Capture mode: GSMTAP — the server is listening for incoming UDP/GSMTAP traffic. Ensure a GSMTAP source (e.g. simtrace2-pysniff or simtrace2-sniff) is feeding the server.');
case 'direct':
return t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.');
case 'disabled':
return t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.');
default:
return '';
}
}
function connectionStatusText() {
if (STATE.serverAvailable) {
return '⬤ ' + t('Connected') + ' — ' + STATE.serverName + ' v' + STATE.serverVersion;
}
return '⬤ ' + t('Not connected — ensure the server is running on the URL above');
}
function updateCaptureModeLine() {
const elm = el('capture-mode');
const mode = STATE.captureMode;
elm.textContent = captureModeText();
elm.className = 'capture-mode' + (mode ? ' ' + mode : '');
}
function connectServer() {
if (_connTimer) return;
_connTimer = setInterval(checkConnection, 5000);
checkConnection();
}
function disconnectServer() {
if (STATE.captureActive && !confirm(t('Capture is still running on the server — it will continue after you disconnect. Disconnect?'))) return;
if (_connTimer) {
clearInterval(_connTimer);
_connTimer = null;
}
STATE.serverAvailable = false;
STATE.serverName = '';
STATE.serverVersion = '';
STATE.captureMode = '';
el('conn-status').textContent = '⬤ ' + t('Disconnected — click Connect to resume');
el('conn-status').className = 'conn-status disconnected';
el('capture-mode').textContent = '';
el('capture-mode').className = 'capture-mode';
el('version-warn').textContent = '';
el('conn-hint').textContent = '';
el('conn-instructions').innerHTML = '';
el('status-dot').className = 'status-dot off';
el('status-text').textContent = t('Not connected');
const btn = el('connect-btn');
btn.textContent = t('Connect');
btn.classList.add('disconnected');
updateCaptureButton();
stopPolling();
renderTraceEmptyState();
}
async function checkConnection() {
try {
const data = await API.status();
STATE.serverAvailable = true;
STATE.captureMode = data.capture_mode || '';
STATE.serverName = data.server || 'simtrace-analyser-server';
STATE.serverVersion = data.version || '';
STATE.deviceConnected = data.device_connected;
el('conn-status').textContent = connectionStatusText();
el('conn-status').className = 'conn-status connected';
updateVersionWarn(data.version);
updateCaptureModeLine();
el('conn-hint').textContent = '';
el('conn-instructions').innerHTML = '';
el('connect-btn').textContent = t('Disconnect');
el('connect-btn').classList.remove('disconnected');
el('status-dot').className = 'status-dot ' + (data.capture_active ? 'live' : 'paused');
el('status-text').textContent = data.capture_active ? t('Capturing') : t('Connected');
if (data.capture_active) {
if (data.session_id && data.session_id !== STATE.currentSessionId) {
// A different capture session than the one we were viewing —
// reset the stream so we re-fetch it from the start.
STATE.currentSessionId = data.session_id;
STATE.messages = [];
_lastAfter = 0;
STATE.mapExpanded.clear();
}
STATE.captureActive = true;
STATE.currentSessionId = data.session_id;
startPolling();
} else {
STATE.captureActive = false;
}
updateCaptureButton();
renderTraceEmptyState();
} catch (e) {
STATE.serverAvailable = false;
STATE.captureMode = '';
STATE.serverName = '';
STATE.serverVersion = '';
el('conn-status').textContent = connectionStatusText();
el('conn-status').className = 'conn-status disconnected';
updateCaptureModeLine();
el('version-warn').textContent = '';
const url = STATE.serverUrl || location.origin;
el('conn-hint').textContent = t(isLikelyBlocked() ? HINT_BLOCKED : HINT_UNREACHABLE).replace('{url}', url);
el('connect-btn').textContent = t('Connect');
el('connect-btn').classList.add('disconnected');
el('conn-instructions').innerHTML = `
<h3>${t('Setup')}</h3>
<ol>
<li>${t('Clone and install simtrace2-pysniff:')}<br>
<code>git clone https://github.com/anttro/simtrace2-pysniff.git</code><br>
<code>cd simtrace2-pysniff && pip install -e .</code></li>
<li>${t('Start the server in direct mode (recommended):')}<br>
<code>./server-start.sh --capture direct</code><br>
<span style="color:var(--text-muted);">${t('Requires SIMtrace2 hardware connected via USB; captures directly from the device.')}</span></li>
</ol>
<p style="margin-top:8px;">${t('Then set the URL above to http://127.0.0.1:8081 and return to the Trace tab.')}</p>
<h3 style="margin-top:20px;">${t('Alternative: GSMTAP mode')}</h3>
<p style="color:var(--text-muted); margin-top:4px;">${t('Use an external sniffer alongside the server. This mode is compatible with the original Osmocom simtrace2-sniff.')}</p>
<ol style="margin-top:4px;">
<li>${t('Start the server:')}<br>
<code>./server-start.sh --capture gsmtap</code></li>
<li>${t('Start a GSMTAP source in another terminal — either:')}<br>
<code>./sniff-start.sh --gsmtap 127.0.0.1</code> &nbsp;(simtrace2-pysniff)<br>
&nbsp;or<br>
<code>simtrace2-sniff --gsmtap-ip 127.0.0.1</code> &nbsp;${t('(original Osmocom tool)')}</li>
</ol>`;
el('status-dot').className = 'status-dot off';
el('status-text').textContent = t('Not connected');
updateCaptureButton();
renderTraceEmptyState();
}
}
// ──────────────────── Tabs ────────────────────
function switchTab(name) {
STATE.activeTab = name;
document.querySelectorAll('.header-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
if (name === 'sessions') loadSessions();
if (name === 'settings') checkConnection();
}
// ──────────────────── Search / Filter ────────────────────
let _searchTimer = null;
function onSearchChange() {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(renderMessages, 200);
}
function onFilterChange() {
renderMessages();
}
// ──────────────────── Versioning ────────────────────
const PWA_VERSION = '1.8.0';
const MIN_SERVER_VERSION = '1.0.0';
function parseVersion(v) {
const parts = String(v || '').trim().split('.').map(n => parseInt(n, 10));
if (parts.length < 2 || parts.some(isNaN)) return null;
return parts;
}
function compareVersions(a, b) {
const pa = parseVersion(a);
const pb = parseVersion(b);
if (pa === null || pb === null) return null;
for (let i = 0; i < 3; i++) {
const da = pa[i] || 0;
const db = pb[i] || 0;
if (da !== db) return da < db ? -1 : 1;
}
return 0;
}
// ──────────────────── i18n ────────────────────
let currentLang = 'en';
const HINT_BLOCKED = 'The browser likely blocked access to the local server. Allow local network access for this site (Chrome/Edge/Vivaldi: Site settings → Local network access → Allow) and confirm simtrace2-pysniff-server is running at {url}.';
const HINT_UNREACHABLE = 'Couldn\'t reach the server. Check that simtrace2-pysniff-server is running and the URL is correct ({url}). If this page is served over HTTPS, also allow local network access in the browser (Site settings → Local network access → Allow).';
const LANG_RU = {
'Trace': 'Трассировка',
'Sessions': 'Сессии',
'Settings': 'Настройки',
'trace analyser': 'анализатор трейсов',
'Install PWA': 'Установить PWA',
'Search hex bytes...': 'Поиск по данным (hex)...',
'All commands': 'Все команды',
'Filter': 'Фильтр',
'Start Capture': 'Начать захват',
'Stop Capture': 'Остановить захват',
'Connect to server': 'Подключиться к серверу',
'Click an APDU to see details': 'Нажмите на APDU для просмотра деталей',
'Command Stats': 'Статистика команд',
'Stats': 'Статистика',
'Map': 'Обзор',
'Reads:': 'Чтение:',
'Writes:': 'Запись:',
'Read/write:': 'Чтение/запись:',
'{n} files': '{n} файлов',
'Not connected': 'Не подключено',
'Live': 'Захват',
'Session': 'Сессия',
'Connected': 'Подключено',
'Capturing': 'Захват',
'No capture active': 'Нет активного захвата',
'No commands': 'Нет команд',
'No saved sessions': 'Нет сохранённых сессий',
'Start a capture from the Trace tab to create sessions.': 'Начните захват на вкладке «Трассировка», чтобы создать сессии.',
'Import PCAP': 'Импорт PCAP',
'New capture': 'Новый захват',
'Server': 'Сервер',
'device disconnected — reconnecting…': 'устройство отключено — переподключение…',
'device disconnected — messages may be missing': 'устройство отключалось — сообщения могли быть потеряны',
'The device disconnected and reconnected — some APDUs may be missing from this trace.': 'Устройство отключалось и переподключалось — часть APDU может отсутствовать в трейсе.',
'LIVE': 'В ЭФИРЕ',
'Opening a session stops the active capture. Continue?': 'Открытие сессии остановит активный захват. Продолжить?',
'Capture is still running on the server — it will continue after you disconnect. Disconnect?': 'Захват всё ещё выполняется на сервере — он продолжится после отключения. Отключиться?',
'Same origin': 'Тот же источник',
'Custom URL': 'Свой URL',
'Connect': 'Подключить',
'Disconnect': 'Отключить',
'Disconnected — click Connect to resume': 'Отключено — нажмите «Подключить», чтобы возобновить',
'Not connected — ensure the server is running on the URL above': 'Не подключено — убедитесь, что сервер запущен по указанному URL',
'Delete Session': 'Удалить сессию',
'Delete': 'Удалить',
'and all its APDU data? This cannot be undone.': 'и все данные APDU? Это действие необратимо.',
'Cancel': 'Отмена',
'Session #': 'Сессия #',
'Session: ': 'Сессия: ',
'Session name...': 'Имя сессии...',
'msgs': 'сообщ.',
'active': 'активна',
'Download PCAP': 'Скачать PCAP',
'{n} messages': '{n} сообщений',
'{n} session(s) · {size}': '{n} сессий · {size}',
'{n} session · {size}': '{n} сессия · {size}',
'Importing…': 'Импорт…',
'Import failed': 'Ошибка импорта',
'Imported \'{name}\' — {n} messages': 'Импортировано «{name}» — {n} сообщений',
'Failed to start capture: ': 'Не удалось начать захват: ',
'Failed to load session': 'Не удалось загрузить сессию',
'Failed to delete session': 'Не удалось удалить сессию',
'No GSMTAP data found in trace': 'В трассировке нет данных GSMTAP',
'Empty file': 'Пустой файл',
'Install from the address bar:\nclick the install icon (⊕ / monitor icon) at the right\nend of Chrome\'s address bar, then "Install SIMtrace Analyser".':
'Установка из адресной строки:\nнажмите значок установки (⊕ / значок монитора) в правом\nконце адресной строки Chrome, затем «Установить SIMtrace Analyser».',
[HINT_BLOCKED]: 'Браузер, вероятно, заблокировал доступ к локальному серверу. Разрешите доступ к локальной сети для этого сайта (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить) и убедитесь, что simtrace2-pysniff-server запущен по адресу {url}.',
[HINT_UNREACHABLE]: 'Не удалось связаться с сервером. Проверьте, что simtrace2-pysniff-server запущен и URL указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Настройки сайта → Доступ к локальной сети → Разрешить).',
'Setup': 'Настройка',
'Clone and install simtrace2-pysniff:': 'Клонируйте и установите simtrace2-pysniff:',
'Start the server in direct mode (recommended):': 'Запустите сервер в режиме direct (рекомендуется):',
'Requires SIMtrace2 hardware connected via USB; captures directly from the device.': 'Требуется оборудование SIMtrace2, подключённое по USB; захват выполняется напрямую с устройства.',
'Then set the URL above to http://127.0.0.1:8081 and return to the Trace tab.': 'Затем укажите URL выше (http://127.0.0.1:8081) и вернитесь на вкладку «Трассировка».',
'Alternative: GSMTAP mode': 'Альтернатива: режим GSMTAP',
'Use an external sniffer alongside the server. This mode is compatible with the original Osmocom simtrace2-sniff.': 'Используйте внешний сниффер вместе с сервером. Этот режим совместим с оригинальным инструментом Osmocom simtrace2-sniff.',
'Start the server:': 'Запустите сервер:',
'Start a GSMTAP source in another terminal — either:': 'Запустите источник GSMTAP в другом терминале — либо:',
'(original Osmocom tool)': '(оригинальный инструмент Osmocom)',
'Server version unknown — update simtrace2-pysniff-server.': 'Версия сервера неизвестна — обновите simtrace2-pysniff-server.',
'Server version {v} is outdated. Please update simtrace2-pysniff-server.': 'Версия сервера {v} устарела. Обновите simtrace2-pysniff-server.',
'Server version {v} is newer than expected. Update simtrace2-pysniff for full compatibility.': 'Версия сервера {v} новее ожидаемой. Обновите simtrace2-pysniff для полной совместимости.',
'Capture mode: GSMTAP — the server is listening for incoming UDP/GSMTAP traffic. Ensure a GSMTAP source (e.g. simtrace2-pysniff or simtrace2-sniff) is feeding the server.': 'Режим захвата: GSMTAP — сервер ожидает входящий UDP/GSMTAP-трафик. Убедитесь, что источник GSMTAP (например, simtrace2-pysniff или simtrace2-sniff) передаёт данные серверу.',
'Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.': 'Режим захвата: direct — сервер захватывает данные с устройства SIMtrace2. Не отключайте устройство.',
'Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.': 'Захват отключён — доступны только ранее записанные трассировки (сохранённые сессии / импорт PCAP).',
'Capture unavailable': 'Захват недоступен',
'Open a saved session from the Sessions tab.': 'Откройте сохранённую сессию на вкладке «Сессии».',
'Capture disabled': 'Захват отключён',
};
function t(text) {
if (currentLang === 'ru' && LANG_RU[text]) return LANG_RU[text];
return text;
}
function translatePage() {
document.querySelectorAll('[data-l10n]').forEach(el => {
const key = el.getAttribute('data-l10n');
if (el.tagName === 'INPUT') {
el.placeholder = t(key);
} else {
el.textContent = t(key);
}
});
}
function applyLang() {
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
}
function toggleLang() {
currentLang = currentLang === 'en' ? 'ru' : 'en';
localStorage.setItem('lang', currentLang);
applyLang();
translatePage();
updateStatus();
updateCaptureButton();
renderMessages();
loadSessions();
checkConnection();
}
// ──────────────────── Theme ────────────────────
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
document.getElementById('theme-btn').textContent = theme === 'dark' ? '☀️' : '🌙';
document.querySelector('meta[name="theme-color"]').setAttribute(
'content', theme === 'dark' ? '#0f172a' : '#fafafa');
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
}
// ──────────────────── PWA Install ────────────────────
let _deferredInstallPrompt = null;
function hideInstallBtn() {
const btn = document.getElementById('install-btn');
if (btn) btn.style.display = 'none';
}
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
_deferredInstallPrompt = e;
console.log('beforeinstallprompt fired — install prompt available');
});
window.addEventListener('appinstalled', () => {
console.log('appinstalled fired');
_deferredInstallPrompt = null;
hideInstallBtn();
});
async function promptInstall() {
if (_deferredInstallPrompt) {
_deferredInstallPrompt.prompt();
const choice = await _deferredInstallPrompt.userChoice;
console.log('install userChoice:', choice.outcome);
if (choice.outcome === 'accepted') {
_deferredInstallPrompt = null;
hideInstallBtn();
}
return;
}
alert(t('Install from the address bar:\nclick the install icon (⊕ / monitor icon) at the right\nend of Chrome\'s address bar, then "Install SIMtrace Analyser".'));
}
// ──────────────────── Init ────────────────────
function init() {
const saved = localStorage.getItem('theme');
if (saved) {
applyTheme(saved);
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
applyTheme('light');
}
const userLang = (navigator.language || '').split('-')[0];
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
applyLang();
translatePage();
el('pwa-version').textContent = 'v' + PWA_VERSION;
syncServerSourceUi();
renderTraceEmptyState();
document.querySelectorAll('.header-tab').forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
$on('capture-btn', 'click', () => {
if (!STATE.serverAvailable) {
switchTab('settings');
return;
}
if (STATE.captureActive) return stopCapture();
if (STATE.sessionViewId) {
el('session-name-display').style.display = 'none';
STATE.sessionViewId = null;
STATE.currentSessionName = null;
updateCaptureButton();
}
startCapture();
});
$on('new-capture-btn', 'click', () => {
if (!STATE.serverAvailable) {
switchTab('settings');
return;
}
if (STATE.captureActive) return stopCapture();
el('session-name-display').style.display = 'none';
STATE.sessionViewId = null;
STATE.currentSessionName = null;
updateCaptureButton();
switchTab('trace');
startCapture();
});
$on('import-pcap-btn', 'click', () => el('import-pcap-file').click());
$on('import-pcap-file', 'change', () => {
const file = el('import-pcap-file').files[0];
if (file) importPcapFile(file);
el('import-pcap-file').value = '';
});
$on('search-input', 'input', onSearchChange);
$on('filter-select', 'change', onFilterChange);
$on('server-source', 'change', () => {
if (el('server-source').value === 'same') {
STATE.serverUrl = '';
localStorage.setItem('serverUrl', '');
syncServerSourceUi();
checkConnection();
} else {
if (!el('server-url').value.trim()) {
el('server-url').value = 'http://127.0.0.1:8081';
}
STATE.serverUrl = el('server-url').value.trim();
localStorage.setItem('serverUrl', STATE.serverUrl);
syncServerSourceUi();
checkConnection();
}
});
$on('server-url', 'change', () => {
STATE.serverUrl = el('server-url').value.trim();
localStorage.setItem('serverUrl', STATE.serverUrl);
checkConnection();
});
$on('connect-btn', 'click', () => {
if (STATE.serverAvailable) {
disconnectServer();
} else {
connectServer();
}
});
$on('install-btn', 'click', promptInstall);
$on('delete-cancel', 'click', () => {
el('delete-modal').classList.remove('show');
STATE.pendingDeleteId = null;
});
$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();
const start = parseInt(toggle.dataset.start);
if (STATE.mapExpanded.has(start)) STATE.mapExpanded.delete(start);
else STATE.mapExpanded.add(start);
renderSidePanel();
return;
}
const mapItem = e.target.closest('.map-item');
if (mapItem) {
focusMessage(parseInt(mapItem.dataset.start));
return;
}
const item = e.target.closest('.side-item');
if (!item) return;
scrollToType(item.dataset.key);
});
connectServer();
el('apdu-list').addEventListener('click', e => {
const entry = e.target.closest('.apdu-entry');
if (!entry) return;
const idx = parseInt(entry.dataset.idx);
const msg = STATE.messages[idx];
if (!msg) return;
showDetail(msg, entry);
});
updateCaptureButton();
}
init();
</script>
</body>
</html>