Fix GSM dashboard counters, improve lists, add device detail modal

Wire SIGNALS/DEVICES/CROWD counters to monitor_heartbeat SSE data so
they update in real-time during monitoring. Redesign device list items
as richer cards with type badges, TA/distance, and observation counts.
Add clickable device detail modal with full device info and copy
support. Improve tower list with signal strength bars. Widen right
sidebar and bump list font sizes for readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-08 20:24:51 +00:00
parent 7d69cac7e7
commit 98f6d18bea
+483 -24
View File
@@ -428,7 +428,7 @@
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 280px 1fr 300px;
grid-template-columns: 280px 1fr 340px;
grid-template-rows: 1fr auto;
gap: 0;
height: calc(100vh - 160px);
@@ -821,16 +821,15 @@
}
.tracked-list-content {
max-height: 400px;
overflow-y: auto;
}
.list-item {
padding: 10px 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.2s;
font-size: 11px;
font-size: 12px;
}
.list-item:hover {
@@ -861,9 +860,9 @@
}
.list-item-details {
font-size: 10px;
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
line-height: 1.5;
}
.rogue-indicator {
@@ -1098,10 +1097,289 @@
/* Responsive adjustments */
@media (max-width: 1400px) {
.dashboard {
grid-template-columns: 250px 1fr 280px;
grid-template-columns: 250px 1fr 300px;
}
}
/* Signal Strength Bar */
.signal-bar-container {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.signal-bar-track {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
overflow: hidden;
}
.signal-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.signal-bar-label {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-dim);
min-width: 50px;
text-align: right;
}
/* Device Card Styles */
.device-card {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
}
.device-card:hover {
background: rgba(74, 163, 255, 0.08);
border-left: 3px solid var(--accent-cyan);
}
.device-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.device-card-id {
font-weight: 700;
font-family: var(--font-mono);
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.device-type-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.device-type-badge.imsi {
background: rgba(56, 193, 128, 0.2);
color: var(--accent-green);
border: 1px solid rgba(56, 193, 128, 0.3);
}
.device-type-badge.tmsi {
background: rgba(74, 163, 255, 0.2);
color: var(--accent-cyan);
border: 1px solid rgba(74, 163, 255, 0.3);
}
.device-card-time {
font-size: 10px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.device-card-mid {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.device-card-mid span {
display: flex;
align-items: center;
gap: 3px;
}
.device-card-bottom {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
}
.device-seen-badge {
padding: 1px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
}
.device-seen-badge.new {
background: rgba(225, 194, 107, 0.15);
color: var(--accent-yellow);
}
.device-seen-badge.returning {
background: rgba(56, 193, 128, 0.15);
color: var(--accent-green);
}
/* Device Detail Modal */
.device-detail-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
.device-detail-modal.active {
display: flex;
justify-content: center;
align-items: center;
}
.device-detail-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(11, 17, 24, 0.9);
backdrop-filter: blur(4px);
}
.device-detail-content {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 420px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 40px rgba(74, 163, 255, 0.25);
animation: slideUp 0.3s ease-out;
}
.device-detail-header {
padding: 14px 18px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.device-detail-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
color: var(--accent-cyan);
text-transform: uppercase;
}
.device-detail-close {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
background: var(--bg-dark);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.device-detail-close:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
.device-detail-body {
padding: 16px 18px;
overflow-y: auto;
flex: 1;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.detail-field {
display: flex;
flex-direction: column;
gap: 3px;
}
.detail-field.full-width {
grid-column: 1 / -1;
}
.detail-field-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: var(--font-mono);
}
.detail-field-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: 6px;
}
.detail-copy-btn {
background: none;
border: 1px solid var(--border-color);
color: var(--text-dim);
border-radius: 3px;
padding: 2px 6px;
font-size: 9px;
cursor: pointer;
transition: all 0.2s;
font-family: var(--font-mono);
}
.detail-copy-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.detail-section {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 4px;
}
.detail-section-title {
font-size: 10px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
@media (max-width: 1024px) {
.dashboard {
grid-template-columns: 1fr;
@@ -1133,6 +1411,16 @@
{% set active_mode = 'gsm' %}
{% include 'partials/nav.html' with context %}
<!-- API Key Banner (shown when not configured) -->
<div id="apiKeyBanner" style="display: none; position: relative; z-index: 10; background: linear-gradient(90deg, rgba(226, 93, 93, 0.15), rgba(226, 93, 93, 0.05)); border-bottom: 1px solid var(--accent-red); padding: 8px 16px; font-size: 11px; color: var(--text-primary);">
<span style="color: var(--accent-red); font-weight: 600;">OpenCellID API key not configured</span>
— tower locations won't appear on the map.
<a href="#" onclick="showSettings(); switchSettingsTab('apikeys'); return false;" style="color: var(--accent-cyan); text-decoration: underline; margin-left: 4px;">Set your key in Settings &gt; API Keys</a>
<button onclick="document.getElementById('apiKeyBanner').style.display='none'" style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px;">&times;</button>
</div>
{% include 'partials/settings-modal.html' %}
<!-- Statistics Strip -->
<div class="stats-strip">
<div class="stats-strip-inner">
@@ -1488,6 +1776,19 @@
</div>
</main>
<!-- Device Detail Modal -->
<div id="deviceDetailModal" class="device-detail-modal">
<div class="device-detail-backdrop" onclick="closeDeviceDetail()"></div>
<div class="device-detail-content">
<div class="device-detail-header">
<span class="device-detail-title">Device Detail</span>
<button class="device-detail-close" onclick="closeDeviceDetail()">&times;</button>
</div>
<div class="device-detail-body" id="deviceDetailBody">
</div>
</div>
</div>
<script>
// ============================================
// STATE
@@ -1547,6 +1848,17 @@
initDeviceSelector();
startUtcClock();
updateBandSelector(); // Initialize band selector with default region (Europe)
// Check API key status and show banner if not configured
fetch('/gsm_spy/settings/api_key')
.then(r => r.json())
.then(data => {
if (!data.configured) {
const banner = document.getElementById('apiKeyBanner');
if (banner) banner.style.display = 'block';
}
})
.catch(() => {});
});
function initMap() {
@@ -2130,18 +2442,25 @@
}, 1000);
}
function updateMonitorStatus(elapsed, packets, devices) {
function updateMonitorStatus(elapsed, packets, devicesCount) {
const overlay = document.getElementById('monitorStatus');
if (overlay.style.display === 'none') return;
document.getElementById('monitorElapsed').textContent = formatElapsed(elapsed);
document.getElementById('monitorPackets').textContent = packets;
document.getElementById('monitorDevices').textContent = devices;
document.getElementById('monitorDevices').textContent = devicesCount;
// Sync local timer with server elapsed
monitorStartTime = Date.now() - (elapsed * 1000);
// Flash activity indicator on heartbeat
const activity = document.getElementById('monitorActivity');
activity.textContent = packets > 0 ? 'Capturing' : 'Listening...';
activity.style.color = packets > 0 ? 'var(--accent-green, #4caf50)' : '';
// Sync strip counters from heartbeat data
stats.totalSignals = packets;
if (devicesCount > stats.totalDevices) {
stats.totalDevices = devicesCount;
}
updateStatsDisplay();
}
function hideMonitorStatus() {
@@ -2159,13 +2478,26 @@
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
function getSignalBarInfo(dbm) {
// Map dBm to percentage and color
// Typical GSM range: -110 dBm (very weak) to -50 dBm (very strong)
if (dbm == null || isNaN(dbm)) return null;
const val = parseFloat(dbm);
const pct = Math.max(0, Math.min(100, ((val + 110) / 60) * 100));
let color;
if (pct >= 60) color = 'var(--accent-green)';
else if (pct >= 30) color = 'var(--accent-yellow)';
else color = 'var(--accent-red)';
return { pct: pct, color: color };
}
function updateTowersList() {
const listDiv = document.getElementById('towersList');
const towerCount = Object.keys(towers).length;
console.log(`[GSM SPY] updateTowersList: ${towerCount} towers, listDiv exists: ${!!listDiv}`);
if (towerCount === 0) {
listDiv.innerHTML = '<div class="no-data"><div>No towers detected</div></div>';
listDiv.innerHTML = '<div class="no-data"><div>No towers detected</div><div style="font-size: 10px; margin-top: 5px;">Start scanner to begin</div></div>';
return;
}
@@ -2175,6 +2507,8 @@
const signalText = tower.signal_strength != null ? escapeHtml(tower.signal_strength) + ' dBm' : '';
const operatorText = tower.operator ? escapeHtml(tower.operator) : '';
const metaText = operatorText || (escapeHtml(tower.mcc) + '-' + escapeHtml(tower.mnc));
const sigBar = getSignalBarInfo(tower.signal_strength);
html += `
<div class="list-item ${selected}" onclick="selectTower('${escapeHtml(key)}')">
<div class="list-item-header">
@@ -2183,8 +2517,15 @@
${tower.rogue ? '<span class="rogue-indicator"></span>' : ''}
</div>
<div class="list-item-details">
LAC ${escapeHtml(tower.lac)} | ARFCN ${escapeHtml(tower.arfcn)}${signalText ? ' | ' + signalText : ''}${tower.lat != null ? ' | <span style="color:var(--accent-cyan)">Located</span>' : ''}
LAC ${escapeHtml(tower.lac)} | ARFCN ${escapeHtml(tower.arfcn)}${tower.lat != null ? ' | <span style="color:var(--accent-cyan)">Located</span>' : ''}
</div>
${sigBar ? `
<div class="signal-bar-container">
<div class="signal-bar-track">
<div class="signal-bar-fill" style="width: ${sigBar.pct}%; background: ${sigBar.color};"></div>
</div>
<span class="signal-bar-label">${signalText}</span>
</div>` : ''}
</div>
`;
}
@@ -2197,7 +2538,12 @@
// ============================================
function updateDevice(data) {
const key = data.imsi || data.tmsi || `device_${Date.now()}`;
const existing = devices[key];
const seenCount = existing ? (existing.seen_count || 1) + 1 : 1;
const firstSeen = existing ? existing.first_seen : data.timestamp;
devices[key] = data;
devices[key].seen_count = seenCount;
devices[key].first_seen = firstSeen;
// Check if device has valid coordinates before creating marker
if (!data.lat || !data.lon) {
@@ -2250,24 +2596,40 @@
const listDiv = document.getElementById('devicesList');
if (Object.keys(devices).length === 0) {
listDiv.innerHTML = '<div class="no-data"><div>No devices tracked</div></div>';
listDiv.innerHTML = '<div class="no-data"><div>No devices tracked</div><div style="font-size: 10px; margin-top: 5px;">Devices will appear here</div></div>';
return;
}
let html = '';
for (const [key, device] of Object.entries(devices)) {
const identifier = device.imsi || device.tmsi || 'Unknown';
const location = (device.lat && device.lon)
? `${device.lat.toFixed(6)}, ${device.lon.toFixed(6)}`
: 'Location unknown';
const isIMSI = !!device.imsi;
const typeBadge = isIMSI ? 'imsi' : 'tmsi';
const typeLabel = isIMSI ? 'IMSI' : 'TMSI';
const timeStr = device.timestamp ? new Date(device.timestamp).toLocaleTimeString() : '';
const seenCount = device.seen_count || 1;
const isNew = seenCount <= 1;
const taValue = device.ta != null ? device.ta : null;
const distEst = taValue != null ? (taValue * 554 / 1000).toFixed(1) + ' km' : '';
html += `
<div class="list-item">
<div class="list-item-header">
<span class="list-item-id">${escapeHtml(identifier)}</span>
<span class="list-item-meta">${new Date(device.timestamp).toLocaleTimeString()}</span>
<div class="device-card" onclick="showDeviceDetail('${escapeHtml(key)}')">
<div class="device-card-top">
<div class="device-card-id">
<span style="color: ${isIMSI ? 'var(--accent-green)' : 'var(--accent-cyan)'}">${escapeHtml(identifier)}</span>
<span class="device-type-badge ${typeBadge}">${typeLabel}</span>
</div>
<span class="device-card-time">${escapeHtml(timeStr)}</span>
</div>
<div class="list-item-details">
Tower CID ${escapeHtml(device.cid)} | ${escapeHtml(location)}
<div class="device-card-mid">
<span>CID ${escapeHtml(device.cid)}</span>
${device.lac ? '<span>LAC ' + escapeHtml(device.lac) + '</span>' : ''}
${taValue != null ? '<span>TA ' + escapeHtml(String(taValue)) + '</span>' : ''}
${distEst ? '<span style="color:var(--accent-cyan)">' + escapeHtml(distEst) + '</span>' : ''}
</div>
<div class="device-card-bottom">
<span class="device-seen-badge ${isNew ? 'new' : 'returning'}">${isNew ? 'NEW' : seenCount + 'x seen'}</span>
<span style="color: var(--text-dim); font-size: 10px;">${device.lat ? 'Located' : ''}</span>
</div>
</div>
`;
@@ -2276,6 +2638,95 @@
listDiv.innerHTML = html;
}
function showDeviceDetail(key) {
const device = devices[key];
if (!device) return;
const identifier = device.imsi || device.tmsi || 'Unknown';
const isIMSI = !!device.imsi;
const typeLabel = isIMSI ? 'IMSI' : 'TMSI';
const typeBadgeClass = isIMSI ? 'imsi' : 'tmsi';
const taValue = device.ta != null ? device.ta : null;
const distEst = taValue != null ? (taValue * 554 / 1000).toFixed(1) + ' km' : 'N/A';
const seenCount = device.seen_count || 1;
const firstSeen = device.first_seen ? new Date(device.first_seen).toLocaleString() : 'N/A';
const lastSeen = device.timestamp ? new Date(device.timestamp).toLocaleString() : 'N/A';
const locationStr = (device.lat && device.lon)
? parseFloat(device.lat).toFixed(6) + ', ' + parseFloat(device.lon).toFixed(6)
: 'Unknown';
// Find associated tower info
let towerInfo = 'N/A';
if (device.cid) {
for (const [tKey, tower] of Object.entries(towers)) {
if (String(tower.cid) === String(device.cid)) {
const op = tower.operator ? escapeHtml(tower.operator) : escapeHtml(tower.mcc) + '-' + escapeHtml(tower.mnc);
towerInfo = 'CID ' + escapeHtml(tower.cid) + ' | LAC ' + escapeHtml(tower.lac) + ' | ' + op;
break;
}
}
if (towerInfo === 'N/A') {
towerInfo = 'CID ' + escapeHtml(device.cid);
}
}
const body = document.getElementById('deviceDetailBody');
body.innerHTML = `
<div class="detail-grid">
<div class="detail-field full-width">
<span class="detail-field-label">Identifier</span>
<span class="detail-field-value">
${escapeHtml(identifier)}
<span class="device-type-badge ${typeBadgeClass}">${typeLabel}</span>
<button class="detail-copy-btn" onclick="event.stopPropagation(); navigator.clipboard.writeText('${escapeHtml(identifier)}')">COPY</button>
</span>
</div>
<div class="detail-field">
<span class="detail-field-label">Timing Advance</span>
<span class="detail-field-value">${taValue != null ? escapeHtml(String(taValue)) : 'N/A'}</span>
</div>
<div class="detail-field">
<span class="detail-field-label">Est. Distance</span>
<span class="detail-field-value" style="color: var(--accent-cyan)">${escapeHtml(distEst)}</span>
</div>
<div class="detail-field">
<span class="detail-field-label">Observations</span>
<span class="detail-field-value">${seenCount}</span>
</div>
<div class="detail-field">
<span class="detail-field-label">Location</span>
<span class="detail-field-value" style="font-size: 11px;">${escapeHtml(locationStr)}</span>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">Tower Association</div>
<div class="detail-field" style="margin-bottom: 12px;">
<span class="detail-field-label">Associated Tower</span>
<span class="detail-field-value" style="font-size: 12px;">${towerInfo}</span>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">Observation Timeline</div>
<div class="detail-grid">
<div class="detail-field">
<span class="detail-field-label">First Seen</span>
<span class="detail-field-value" style="font-size: 11px;">${escapeHtml(firstSeen)}</span>
</div>
<div class="detail-field">
<span class="detail-field-label">Last Seen</span>
<span class="detail-field-value" style="font-size: 11px;">${escapeHtml(lastSeen)}</span>
</div>
</div>
</div>
`;
document.getElementById('deviceDetailModal').classList.add('active');
}
function closeDeviceDetail() {
document.getElementById('deviceDetailModal').classList.remove('active');
}
// ============================================
// ALERTS
// ============================================
@@ -2319,6 +2770,8 @@
document.getElementById('stripDevices').textContent = stats.totalDevices;
document.getElementById('stripRogues').textContent = stats.totalRogues;
document.getElementById('stripSignals').textContent = stats.totalSignals;
const liveDeviceCount = Object.keys(devices).length;
document.getElementById('stripCrowd').textContent = liveDeviceCount > 0 ? liveDeviceCount : '-';
}
// ============================================
@@ -2364,10 +2817,14 @@
}
});
// Close modal on ESC key
// Close modals on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.classList.contains('active')) {
closeAnalyticsModal();
if (e.key === 'Escape') {
if (document.getElementById('deviceDetailModal').classList.contains('active')) {
closeDeviceDetail();
} else if (modal.classList.contains('active')) {
closeAnalyticsModal();
}
}
});
});
@@ -2681,6 +3138,8 @@
</script>
<!-- Settings Manager -->
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<!-- Global Navigation Script -->
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body>