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).
This commit is contained in:
catarrh committed 2026-08-15 08:48:07 +03:00
1 parent 0bb3bc82c7
commit 97a7e6034a
7 files changed
+147 -29

No files matched your search

+69 -25
View File
@@ -24,6 +24,7 @@
--warn-bg: #7f1d1d; --warn-bg: #7f1d1d;
--warn: #facc15; --warn: #facc15;
--warn-hover: #eab308; --warn-hover: #eab308;
--warning-bg: #78350f;
--error: #f87171; --error: #f87171;
--code-bg: #1e293b; --code-bg: #1e293b;
--scrollbar-track: #0f172a; --scrollbar-track: #0f172a;
@@ -49,6 +50,7 @@
--warn-bg: #fee2e2; --warn-bg: #fee2e2;
--warn: #f59e0b; --warn: #f59e0b;
--warn-hover: #d97706; --warn-hover: #d97706;
--warning-bg: #fef3c7;
--error: #dc2626; --error: #dc2626;
--code-bg: #f3f4f6; --code-bg: #f3f4f6;
--scrollbar-track: #fafafa; --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 { 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:hover { background: var(--accent-hover); }
.settings-url-row button.disconnected { background: var(--bg-input); } .settings-url-row button.disconnected { background: var(--bg-input); }
.settings-view .conn-status { padding: 12px; border-radius: 4px; margin-bottom: 12px; } .conn-status { padding: 12px; border-radius: 4px; margin-bottom: 12px; }
.settings-view .conn-status.connected { background: var(--accent-bg); color: var(--green); } .conn-status.connected { background: var(--accent-bg); color: var(--green); }
.settings-view .conn-status.disconnected { background: var(--warn-bg); color: var(--error); } .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; } .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: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 { font-size: 12px; color: var(--warn); margin: 8px 0 0; line-height: 1.5; }
.version-warn:empty { display: none; } .version-warn:empty { display: none; }
.conn-instructions { margin-top: 16px; font-size: 13px; color: var(--text-faint); line-height: 1.7; } .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 */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); gap: 16px; } .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 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 */
.modal-overlay { display: none; position: fixed; inset: 0; background: var(--modal-bg); z-index: 100; align-items: center; justify-content: center; } .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: [], typeCounts: [],
pendingDeleteId: null, pendingDeleteId: null,
serverAvailable: false, serverAvailable: false,
serverName: '',
serverVersion: '',
captureMode: '', captureMode: '',
}; };
@@ -762,6 +770,27 @@ function matchesFilter(m, i, filterType) {
return false; 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() { function renderMessages() {
const list = el('apdu-list'); const list = el('apdu-list');
const searchQ = el('search-input').value.toLowerCase(); const searchQ = el('search-input').value.toLowerCase();
@@ -780,15 +809,7 @@ function renderMessages() {
populateFilterOptions(); populateFilterOptions();
if (!STATE.captureActive && !STATE.sessionViewId) { if (!STATE.captureActive && !STATE.sessionViewId) {
if (STATE.captureMode === 'disabled') { renderTraceEmptyState();
list.innerHTML = `<div class="empty-state"><p>${t('Capture unavailable')}</p>
<p style="font-size:12px;">${t('Open a saved session from the Sessions tab.')}</p></div>`;
} else {
list.innerHTML = `<div class="empty-state"><p>${t('No capture active')}</p>
<button id="trace-start-btn">${t('Start Capture')}</button></div>`;
$('#trace-start-btn', list).addEventListener('click', startCapture);
}
updateStatus();
return; return;
} }
@@ -882,6 +903,7 @@ async function stopCapture() {
try { try {
await API.stopCapture(); await API.stopCapture();
STATE.captureActive = false; STATE.captureActive = false;
STATE.currentSessionId = null;
STATE.sessionViewId = null; STATE.sessionViewId = null;
updateCaptureButton(); updateCaptureButton();
updateStatus(); updateStatus();
@@ -1106,23 +1128,33 @@ function updateVersionWarn(serverVersion) {
elv.textContent = ''; elv.textContent = '';
} }
function updateCaptureModeLine() { function captureModeText() {
const elm = el('capture-mode');
switch (STATE.captureMode) { switch (STATE.captureMode) {
case 'gsmtap': 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.'); 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.');
break;
case 'direct': case 'direct':
elm.textContent = t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.'); return t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.');
break;
case 'disabled': case 'disabled':
elm.textContent = t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.'); return t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.');
break;
default: 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() { function connectServer() {
if (_connTimer) return; if (_connTimer) return;
_connTimer = setInterval(checkConnection, 5000); _connTimer = setInterval(checkConnection, 5000);
@@ -1135,9 +1167,13 @@ function disconnectServer() {
_connTimer = null; _connTimer = null;
} }
STATE.serverAvailable = false; STATE.serverAvailable = false;
STATE.serverName = '';
STATE.serverVersion = '';
STATE.captureMode = '';
el('conn-status').textContent = '⬤ ' + t('Disconnected — click Connect to resume'); el('conn-status').textContent = '⬤ ' + t('Disconnected — click Connect to resume');
el('conn-status').className = 'conn-status disconnected'; el('conn-status').className = 'conn-status disconnected';
el('capture-mode').textContent = ''; el('capture-mode').textContent = '';
el('capture-mode').className = 'capture-mode';
el('version-warn').textContent = ''; el('version-warn').textContent = '';
el('conn-hint').textContent = ''; el('conn-hint').textContent = '';
el('conn-instructions').innerHTML = ''; el('conn-instructions').innerHTML = '';
@@ -1152,6 +1188,7 @@ function disconnectServer() {
STATE.captureActive = false; STATE.captureActive = false;
STATE.currentSessionId = null; STATE.currentSessionId = null;
} }
renderTraceEmptyState();
} }
async function checkConnection() { async function checkConnection() {
@@ -1159,7 +1196,9 @@ async function checkConnection() {
const data = await API.status(); const data = await API.status();
STATE.serverAvailable = true; STATE.serverAvailable = true;
STATE.captureMode = data.capture_mode || ''; 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'; el('conn-status').className = 'conn-status connected';
updateVersionWarn(data.version); updateVersionWarn(data.version);
updateCaptureModeLine(); updateCaptureModeLine();
@@ -1174,12 +1213,15 @@ async function checkConnection() {
STATE.currentSessionId = data.session_id; STATE.currentSessionId = data.session_id;
} }
updateCaptureButton(); updateCaptureButton();
renderTraceEmptyState();
} catch (e) { } catch (e) {
STATE.serverAvailable = false; STATE.serverAvailable = false;
STATE.captureMode = ''; 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('conn-status').className = 'conn-status disconnected';
el('capture-mode').textContent = ''; updateCaptureModeLine();
el('version-warn').textContent = ''; el('version-warn').textContent = '';
const url = STATE.serverUrl || location.origin; const url = STATE.serverUrl || location.origin;
el('conn-hint').textContent = t(isLikelyBlocked() ? HINT_BLOCKED : HINT_UNREACHABLE).replace('{url}', url); 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-dot').className = 'status-dot off';
el('status-text').textContent = t('Not connected'); el('status-text').textContent = t('Not connected');
updateCaptureButton(); updateCaptureButton();
renderTraceEmptyState();
} }
} }
@@ -1433,6 +1476,7 @@ function init() {
el('pwa-version').textContent = 'v' + PWA_VERSION; el('pwa-version').textContent = 'v' + PWA_VERSION;
syncServerSourceUi(); syncServerSourceUi();
renderTraceEmptyState();
document.querySelectorAll('.header-tab').forEach(tab => { document.querySelectorAll('.header-tab').forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab)); tab.addEventListener('click', () => switchTab(tab.dataset.tab));
+1 -1
View File
@@ -81,7 +81,7 @@ class GsmtapReceiver:
""" """
try: try:
packet, addr = self._sock.recvfrom(65536) packet, addr = self._sock.recvfrom(65536)
except socket.timeout: except (socket.timeout, OSError):
return None, None return None, None
if len(packet) < _GSMTAP_HDR_SIZE: if len(packet) < _GSMTAP_HDR_SIZE:
+2 -1
View File
@@ -13,6 +13,7 @@ from http.server import HTTPServer
from .server import RequestHandler from .server import RequestHandler
from .database import Database, DEFAULT_DB_PATH from .database import Database, DEFAULT_DB_PATH
from .capture import CaptureManager, GsmtapListener, DirectSniffer from .capture import CaptureManager, GsmtapListener, DirectSniffer
from .. import __version__
def _default_web_dir(): def _default_web_dir():
@@ -56,7 +57,7 @@ def main():
server = HTTPServer((args.host, args.port), RequestHandler) server = HTTPServer((args.host, args.port), RequestHandler)
server.web_dir = args.web_dir 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) print(f' capture mode: {args.capture}', file=sys.stderr)
if args.capture == 'gsmtap': if args.capture == 'gsmtap':
print(f' GSMTAP port: {args.gsmtap_port}', file=sys.stderr) print(f' GSMTAP port: {args.gsmtap_port}', file=sys.stderr)
+7 -2
View File
@@ -94,12 +94,17 @@ class CaptureManager:
def stop_session(self): def stop_session(self):
if self._session_id is None: if self._session_id is None:
return return None
self._backend.stop() 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 sid = self._session_id
self._session_id = None self._session_id = None
self._start_time = 0.0 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 return sid
def _capture_loop(self): def _capture_loop(self):
+3
View File
@@ -293,6 +293,9 @@ class RequestHandler(BaseHTTPRequestHandler):
self._send_error(400, 'No active capture') self._send_error(400, 'No active capture')
return return
session_id = self.capture.stop_session() 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) session = self.db.get_session(session_id)
self._send_json({ self._send_json({
'session_id': session_id, 'session_id': session_id,
+49
View File
@@ -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()
+16
View File
@@ -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()