From 97a7e6034a33149adeb1b3e6b4f0f94d7fac58ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 15 Aug 2026 08:48:07 +0300 Subject: [PATCH] Show status in trace empty-state; discard empty captures; polish capture-mode Frontend: - trace empty-state now shows connection status + capture mode (boxed, colored) in the center, updated live via checkConnection - capture-mode uses amber warning style (gsmtap/disabled), green for direct - stopCapture clears currentSessionId Server: - gsmtap read_packet catches OSError on socket close (no more EBADF traceback on stop) - stop_session joins the loop and discards a session with zero messages; /api/capture/stop reports discarded - startup prints server version Tests: 122 (gsmtap close, empty/non-empty capture lifecycle). --- frontend/index.html | 94 ++++++++++++++++++++-------- simtrace2_pysniff/gsmtap.py | 2 +- simtrace2_pysniff/server/__main__.py | 3 +- simtrace2_pysniff/server/capture.py | 9 ++- simtrace2_pysniff/server/server.py | 3 + tests/test_capture.py | 49 +++++++++++++++ tests/test_gsmtap.py | 16 +++++ 7 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 tests/test_capture.py create mode 100644 tests/test_gsmtap.py diff --git a/frontend/index.html b/frontend/index.html index 132bd00..185136b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,6 +24,7 @@ --warn-bg: #7f1d1d; --warn: #facc15; --warn-hover: #eab308; + --warning-bg: #78350f; --error: #f87171; --code-bg: #1e293b; --scrollbar-track: #0f172a; @@ -49,6 +50,7 @@ --warn-bg: #fee2e2; --warn: #f59e0b; --warn-hover: #d97706; + --warning-bg: #fef3c7; --error: #dc2626; --code-bg: #f3f4f6; --scrollbar-track: #fafafa; @@ -168,12 +170,15 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(- .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); } -.settings-view .conn-status { padding: 12px; border-radius: 4px; margin-bottom: 12px; } -.settings-view .conn-status.connected { background: var(--accent-bg); color: var(--green); } -.settings-view .conn-status.disconnected { background: var(--warn-bg); color: var(--error); } +.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 { 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; } @@ -186,6 +191,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(- /* 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; } @@ -321,6 +327,8 @@ const STATE = { typeCounts: [], pendingDeleteId: null, serverAvailable: false, + serverName: '', + serverVersion: '', captureMode: '', }; @@ -762,6 +770,27 @@ function matchesFilter(m, i, filterType) { 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 + ? `
${modeText}
` + : ''; + const action = STATE.captureMode === 'disabled' + ? `

${t('Open a saved session from the Sessions tab.')}

` + : ``; + list.innerHTML = `
+
${connectionStatusText()}
+ ${modeHtml} + ${action} +
`; + const btn = $('#trace-start-btn', list); + if (btn) btn.addEventListener('click', startCapture); + updateStatus(); +} + function renderMessages() { const list = el('apdu-list'); const searchQ = el('search-input').value.toLowerCase(); @@ -780,15 +809,7 @@ function renderMessages() { populateFilterOptions(); if (!STATE.captureActive && !STATE.sessionViewId) { - if (STATE.captureMode === 'disabled') { - list.innerHTML = `

${t('Capture unavailable')}

-

${t('Open a saved session from the Sessions tab.')}

`; - } else { - list.innerHTML = `

${t('No capture active')}

-
`; - $('#trace-start-btn', list).addEventListener('click', startCapture); - } - updateStatus(); + renderTraceEmptyState(); return; } @@ -882,6 +903,7 @@ async function stopCapture() { try { await API.stopCapture(); STATE.captureActive = false; + STATE.currentSessionId = null; STATE.sessionViewId = null; updateCaptureButton(); updateStatus(); @@ -1106,23 +1128,33 @@ function updateVersionWarn(serverVersion) { elv.textContent = ''; } -function updateCaptureModeLine() { - const elm = el('capture-mode'); +function captureModeText() { switch (STATE.captureMode) { case 'gsmtap': - elm.textContent = 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.'); - break; + 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': - elm.textContent = t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.'); - break; + return t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.'); case 'disabled': - elm.textContent = t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.'); - break; + return t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.'); default: - elm.textContent = ''; + 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); @@ -1135,9 +1167,13 @@ function disconnectServer() { _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 = ''; @@ -1152,6 +1188,7 @@ function disconnectServer() { STATE.captureActive = false; STATE.currentSessionId = null; } + renderTraceEmptyState(); } async function checkConnection() { @@ -1159,7 +1196,9 @@ async function checkConnection() { const data = await API.status(); STATE.serverAvailable = true; STATE.captureMode = data.capture_mode || ''; - el('conn-status').textContent = '⬤ ' + t('Connected') + ' — ' + data.server + ' v' + (data.version || '?'); + STATE.serverName = data.server || 'simtrace-analyser-server'; + STATE.serverVersion = data.version || ''; + el('conn-status').textContent = connectionStatusText(); el('conn-status').className = 'conn-status connected'; updateVersionWarn(data.version); updateCaptureModeLine(); @@ -1174,12 +1213,15 @@ async function checkConnection() { STATE.currentSessionId = data.session_id; } updateCaptureButton(); + renderTraceEmptyState(); } catch (e) { STATE.serverAvailable = false; STATE.captureMode = ''; - el('conn-status').textContent = '⬤ ' + t('Not connected — ensure the server is running on the URL above'); + STATE.serverName = ''; + STATE.serverVersion = ''; + el('conn-status').textContent = connectionStatusText(); el('conn-status').className = 'conn-status disconnected'; - el('capture-mode').textContent = ''; + updateCaptureModeLine(); el('version-warn').textContent = ''; const url = STATE.serverUrl || location.origin; el('conn-hint').textContent = t(isLikelyBlocked() ? HINT_BLOCKED : HINT_UNREACHABLE).replace('{url}', url); @@ -1209,6 +1251,7 @@ async function checkConnection() { el('status-dot').className = 'status-dot off'; el('status-text').textContent = t('Not connected'); updateCaptureButton(); + renderTraceEmptyState(); } } @@ -1433,6 +1476,7 @@ function init() { el('pwa-version').textContent = 'v' + PWA_VERSION; syncServerSourceUi(); + renderTraceEmptyState(); document.querySelectorAll('.header-tab').forEach(tab => { tab.addEventListener('click', () => switchTab(tab.dataset.tab)); diff --git a/simtrace2_pysniff/gsmtap.py b/simtrace2_pysniff/gsmtap.py index b217c9d..e3a7faa 100644 --- a/simtrace2_pysniff/gsmtap.py +++ b/simtrace2_pysniff/gsmtap.py @@ -81,7 +81,7 @@ class GsmtapReceiver: """ try: packet, addr = self._sock.recvfrom(65536) - except socket.timeout: + except (socket.timeout, OSError): return None, None if len(packet) < _GSMTAP_HDR_SIZE: diff --git a/simtrace2_pysniff/server/__main__.py b/simtrace2_pysniff/server/__main__.py index 8e75362..ca231d1 100644 --- a/simtrace2_pysniff/server/__main__.py +++ b/simtrace2_pysniff/server/__main__.py @@ -13,6 +13,7 @@ from http.server import HTTPServer from .server import RequestHandler from .database import Database, DEFAULT_DB_PATH from .capture import CaptureManager, GsmtapListener, DirectSniffer +from .. import __version__ def _default_web_dir(): @@ -56,7 +57,7 @@ def main(): server = HTTPServer((args.host, args.port), RequestHandler) server.web_dir = args.web_dir - print(f'simtrace2-pysniff-server — http://{args.host}:{args.port}', file=sys.stderr) + print(f'simtrace2-pysniff-server v{__version__} — http://{args.host}:{args.port}', file=sys.stderr) print(f' capture mode: {args.capture}', file=sys.stderr) if args.capture == 'gsmtap': print(f' GSMTAP port: {args.gsmtap_port}', file=sys.stderr) diff --git a/simtrace2_pysniff/server/capture.py b/simtrace2_pysniff/server/capture.py index 898085e..03503bd 100644 --- a/simtrace2_pysniff/server/capture.py +++ b/simtrace2_pysniff/server/capture.py @@ -94,12 +94,17 @@ class CaptureManager: def stop_session(self): if self._session_id is None: - return + return None self._backend.stop() - self._db.close_session(self._session_id) + if self._thread is not None: + self._thread.join(timeout=2.0) sid = self._session_id self._session_id = None self._start_time = 0.0 + if self._db.count_messages(sid) == 0: + self._db.delete_session(sid) + return None + self._db.close_session(sid) return sid def _capture_loop(self): diff --git a/simtrace2_pysniff/server/server.py b/simtrace2_pysniff/server/server.py index 4a52ab7..bfe4766 100644 --- a/simtrace2_pysniff/server/server.py +++ b/simtrace2_pysniff/server/server.py @@ -293,6 +293,9 @@ class RequestHandler(BaseHTTPRequestHandler): self._send_error(400, 'No active capture') return session_id = self.capture.stop_session() + if session_id is None: + self._send_json({'session_id': None, 'messages_count': 0, 'discarded': True}) + return session = self.db.get_session(session_id) self._send_json({ 'session_id': session_id, diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..68843d2 --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,49 @@ +"""Tests for CaptureManager session lifecycle (empty-session discard).""" + +import os +import tempfile +import unittest + +from simtrace2_pysniff.server.capture import CaptureManager +from simtrace2_pysniff.server.database import Database + + +class _FakeBackend: + def __init__(self, messages=()): + self._messages = list(messages) + + def start(self): + pass + + def stop(self): + pass + + def iter_messages(self): + yield from self._messages + + +class TestCaptureManager(unittest.TestCase): + def _manager(self, backend): + tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False) + tmp.close() + db = Database(tmp.name) + self.addCleanup(lambda: (db._conn.close(), os.remove(tmp.name))) + return CaptureManager(backend, db), db + + def test_empty_capture_discarded(self): + mgr, db = self._manager(_FakeBackend()) + sid = mgr.start_session() + self.assertIsNotNone(sid) + self.assertIsNone(mgr.stop_session()) + self.assertIsNone(db.get_session(sid)) + + def test_non_empty_capture_kept(self): + mgr, db = self._manager(_FakeBackend([('tpdu', b'\x80\xf2\x00\x00\x00', 0)])) + sid = mgr.start_session() + self.assertEqual(mgr.stop_session(), sid) + self.assertIsNotNone(db.get_session(sid)) + self.assertEqual(db.count_messages(sid), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py new file mode 100644 index 0000000..460112d --- /dev/null +++ b/tests/test_gsmtap.py @@ -0,0 +1,16 @@ +"""Tests for GSMTAP receiver shutdown behavior.""" + +import unittest + +from simtrace2_pysniff.gsmtap import GsmtapReceiver + + +class TestGsmtapReceiver(unittest.TestCase): + def test_read_after_close_returns_none(self): + r = GsmtapReceiver(bind_port=0) + r.close() + self.assertEqual(r.read_packet(), (None, None)) + + +if __name__ == '__main__': + unittest.main()