Files
intercept/templates/gsm_spy_dashboard.html
T
Smittix 98f6d18bea 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>
2026-02-08 20:24:51 +00:00

3147 lines
125 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GSM SPY // INTERCEPT - See the Invisible</title>
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<!-- Core CSS variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--font-sans: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
--border-color: #263246;
--border-glow: #4aa3ff;
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-muted: #9fb0c7;
--text-dim: #6f7f94;
--accent-green: #38c180;
--accent-cyan: #4aa3ff;
--accent-orange: #d6a85e;
--accent-red: #e25d5d;
--accent-yellow: #e1c26b;
--grid-line: rgba(74, 163, 255, 0.1);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
}
body {
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* Animated radar sweep background */
.radar-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none;
z-index: 0;
}
/* Scan line effect */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.25;
box-shadow: 0 0 8px currentColor;
}
@keyframes scan {
0% { top: -4px; }
100% { top: 100vh; }
}
/* Header */
.header {
position: relative;
z-index: 10;
padding: 10px 12px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-height: 52px;
}
.header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.6;
pointer-events: none;
}
.logo {
font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
color: var(--text-primary);
text-transform: uppercase;
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
font-size: 12px;
margin-left: 10px;
letter-spacing: 1px;
}
.status-bar {
display: flex;
gap: 12px;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.inactive {
background: var(--accent-red);
box-shadow: 0 0 10px var(--accent-red);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Stats Strip */
.stats-strip {
position: relative;
z-index: 10;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
padding: 8px 12px;
overflow-x: auto;
}
.stats-strip-inner {
display: flex;
gap: 16px;
align-items: center;
min-width: fit-content;
}
.strip-stat {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 50px;
}
.strip-value {
font-size: 14px;
font-weight: 700;
color: var(--accent-cyan);
font-family: var(--font-mono);
}
.strip-label {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.5px;
font-family: var(--font-mono);
}
.strip-divider {
width: 1px;
height: 30px;
background: var(--border-color);
}
.strip-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-secondary);
}
.strip-time {
font-size: 11px;
color: var(--accent-cyan);
font-family: var(--font-mono);
}
.strip-btn {
padding: 6px 12px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
font-size: 10px;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.strip-btn:hover {
background: var(--bg-panel);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Analytics Overview Modal */
.analytics-modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(11, 17, 24, 0.95);
backdrop-filter: blur(4px);
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.analytics-modal-overlay.active {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.analytics-modal {
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 8px;
max-width: 1200px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 40px rgba(74, 163, 255, 0.3);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.analytics-modal-header {
padding: 16px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.analytics-modal-title {
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
color: var(--accent-cyan);
text-transform: uppercase;
}
.analytics-modal-close {
width: 32px;
height: 32px;
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: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.analytics-modal-close:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
.analytics-modal-content {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.analytics-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 16px;
transition: all 0.2s;
}
.analytics-card:hover {
border-color: var(--accent-cyan);
box-shadow: 0 0 16px rgba(74, 163, 255, 0.2);
}
.analytics-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.analytics-card-icon {
width: 32px;
height: 32px;
background: var(--accent-cyan);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.analytics-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.analytics-card-description {
font-size: 11px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 12px;
}
.analytics-card-stats {
display: flex;
gap: 12px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.analytics-card-stat {
flex: 1;
}
.analytics-card-stat-value {
font-size: 18px;
font-weight: 700;
color: var(--accent-green);
font-family: var(--font-mono);
}
.analytics-card-stat-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Main Dashboard Layout */
.dashboard {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 280px 1fr 340px;
grid-template-rows: 1fr auto;
gap: 0;
height: calc(100vh - 160px);
min-height: 500px;
}
/* Left Sidebar */
.left-sidebar {
background: var(--bg-panel);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
grid-row: 1;
}
/* Main Display (Map) */
.main-display {
position: relative;
grid-row: 1;
background: var(--bg-dark);
}
#gsmMap {
width: 100%;
height: 100%;
background: var(--bg-dark);
}
/* Scan Progress Overlay */
.scan-progress-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(180deg, rgba(10, 14, 20, 0.95) 0%, rgba(10, 14, 20, 0.85) 100%);
border-bottom: 1px solid var(--accent-cyan);
backdrop-filter: blur(8px);
padding: 10px 16px;
}
.scan-progress-inner {
max-width: 600px;
margin: 0 auto;
}
.scan-progress-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 12px;
}
.scan-progress-icon {
color: var(--accent-cyan);
font-size: 14px;
animation: spin 2s linear infinite;
display: inline-block;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#scanStatusText {
color: var(--accent-cyan);
flex: 1;
}
.scan-percent {
color: var(--text-primary);
font-weight: 600;
font-size: 13px;
}
.scan-progress-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
height: 5px;
overflow: hidden;
}
.scan-progress-fill {
background: linear-gradient(90deg, var(--accent-cyan), #4dd0e1);
height: 100%;
width: 0%;
transition: width 0.3s ease;
border-radius: 3px;
box-shadow: 0 0 8px rgba(0, 229, 255, 0.4);
}
/* Monitor Status Overlay */
.monitor-status-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(180deg, rgba(10, 14, 20, 0.95) 0%, rgba(10, 14, 20, 0.85) 100%);
border-bottom: 1px solid var(--accent-green, #4caf50);
backdrop-filter: blur(8px);
padding: 10px 16px;
}
.monitor-status-inner {
max-width: 600px;
margin: 0 auto;
}
.monitor-status-row {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-mono);
font-size: 12px;
}
.monitor-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green, #4caf50);
box-shadow: 0 0 6px var(--accent-green, #4caf50);
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--accent-green, #4caf50); }
50% { opacity: 0.5; box-shadow: 0 0 12px var(--accent-green, #4caf50); }
}
.monitor-label {
color: var(--accent-green, #4caf50);
font-weight: 700;
letter-spacing: 1.5px;
font-size: 11px;
}
.monitor-arfcn {
color: var(--text-primary);
font-weight: 600;
}
.monitor-elapsed {
margin-left: auto;
color: var(--text-secondary);
}
.monitor-stats-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 5px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding-left: 18px;
}
.monitor-stat-sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--text-secondary);
opacity: 0.5;
}
.monitor-listening {
animation: listening-fade 2.5s ease-in-out infinite;
}
@keyframes listening-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Right Sidebar */
.right-sidebar {
background: var(--bg-panel);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
grid-row: 1;
}
/* Panel Styles */
.panel {
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
}
.panel-header {
padding: 10px 12px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
color: var(--text-secondary);
}
.panel-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
}
.panel-content {
padding: 12px;
font-size: 11px;
max-height: 300px;
overflow-y: auto;
}
/* Signal Source Panel */
.signal-source-panel {
flex-shrink: 0;
}
.signal-source-content {
padding: 12px;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 10px;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group select {
width: 100%;
padding: 8px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
/* Region Selector Panel */
.region-panel {
flex-shrink: 0;
}
.region-buttons {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.region-btn {
padding: 10px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.2s;
}
.region-btn:hover {
background: var(--bg-panel);
border-color: var(--accent-cyan);
}
.region-btn.active {
background: var(--accent-cyan);
color: var(--bg-dark);
border-color: var(--accent-cyan);
font-weight: 600;
}
/* Advanced Analysis Panel - Same style as Region Buttons */
.advanced-analysis-panel {
flex-shrink: 0;
}
.analysis-buttons {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.analysis-btn {
padding: 10px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.2s;
}
.analysis-btn:hover {
background: var(--bg-panel);
border-color: var(--accent-cyan);
}
.analysis-btn.active {
background: var(--accent-cyan);
color: var(--bg-dark);
border-color: var(--accent-cyan);
font-weight: 600;
}
/* Selected Tower Panel */
.selected-tower-panel {
flex-shrink: 0;
min-height: 200px;
}
.no-selection {
padding: 30px 20px;
text-align: center;
color: var(--text-dim);
font-size: 11px;
}
.tower-info {
padding: 12px;
font-size: 11px;
line-height: 1.6;
}
.tower-info-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
}
.tower-info-row:last-child {
border-bottom: none;
}
.tower-info-label {
color: var(--text-dim);
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.5px;
}
.tower-info-value {
color: var(--accent-cyan);
font-family: var(--font-mono);
font-weight: 600;
}
.tower-rogue-badge {
display: inline-block;
padding: 2px 6px;
background: var(--accent-red);
color: white;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* Tracked Lists */
.tracked-list {
flex: 1;
overflow-y: auto;
}
.tracked-list-content {
overflow-y: auto;
}
.list-item {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.2s;
font-size: 12px;
}
.list-item:hover {
background: var(--bg-panel);
}
.list-item.selected {
background: rgba(74, 163, 255, 0.1);
border-left: 3px solid var(--accent-cyan);
}
.list-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.list-item-id {
font-weight: 700;
color: var(--accent-cyan);
font-family: var(--font-mono);
}
.list-item-meta {
font-size: 9px;
color: var(--text-dim);
}
.list-item-details {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
}
.rogue-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
animation: blink 1s infinite;
margin-left: 6px;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0.3; }
}
.no-data {
padding: 30px 20px;
text-align: center;
color: var(--text-dim);
font-size: 11px;
}
/* Alerts Panel */
.alerts-panel {
flex-shrink: 0;
max-height: 250px;
}
.alert-item {
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
background: rgba(226, 93, 93, 0.05);
border-left: 3px solid var(--accent-red);
font-size: 10px;
line-height: 1.5;
}
.alert-time {
color: var(--text-dim);
font-size: 9px;
margin-bottom: 4px;
}
.alert-message {
color: var(--accent-red);
font-weight: 600;
}
/* Controls Bar */
.controls-bar {
grid-column: 1 / -1;
grid-row: 2;
background: var(--bg-panel);
border-top: 1px solid var(--border-color);
padding: 12px;
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 10px;
}
.control-group-label {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 1px;
text-transform: uppercase;
margin-right: 6px;
}
.control-group-items {
display: flex;
align-items: center;
gap: 8px;
}
.control-group input[type="text"],
.control-group input[type="number"] {
padding: 6px 8px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.control-group select {
padding: 6px 8px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.start-btn {
padding: 8px 20px;
background: var(--accent-green);
border: none;
border-radius: 4px;
color: white;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
}
.start-btn:hover {
background: #2da868;
box-shadow: 0 0 12px var(--accent-green);
}
.start-btn.active {
background: var(--accent-red);
animation: pulse-btn 2s infinite;
}
.start-btn.active:hover {
background: #c94545;
}
@keyframes pulse-btn {
0%, 100% { box-shadow: 0 0 8px var(--accent-red); }
50% { box-shadow: 0 0 20px var(--accent-red); }
}
.gps-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--accent-green);
}
.gps-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
animation: pulse 2s infinite;
}
/* Map marker animations */
@keyframes marker-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.3;
}
}
/* Device blip pulse animation */
.device-blip {
animation: device-pulse 5s ease-out forwards;
}
@keyframes device-pulse {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(3);
opacity: 0;
}
}
/* Custom Leaflet marker styles */
.tower-marker {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--accent-green);
background: rgba(56, 193, 128, 0.3);
box-shadow: 0 0 10px var(--accent-green);
}
.tower-marker.rogue {
border-color: var(--accent-red);
background: rgba(226, 93, 93, 0.3);
box-shadow: 0 0 10px var(--accent-red);
animation: blink 1s infinite;
}
.device-marker {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-red);
box-shadow: 0 0 8px var(--accent-red);
}
/* Scrollbar styling */
.tracked-list-content::-webkit-scrollbar,
.panel-content::-webkit-scrollbar {
width: 6px;
}
.tracked-list-content::-webkit-scrollbar-track,
.panel-content::-webkit-scrollbar-track {
background: var(--bg-dark);
}
.tracked-list-content::-webkit-scrollbar-thumb,
.panel-content::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.tracked-list-content::-webkit-scrollbar-thumb:hover,
.panel-content::-webkit-scrollbar-thumb:hover {
background: var(--accent-cyan);
}
/* Responsive adjustments */
@media (max-width: 1400px) {
.dashboard {
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;
grid-template-rows: auto 1fr auto;
}
.left-sidebar,
.right-sidebar {
display: none;
}
}
</style>
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
GSM SPY
<span>// INTERCEPT - See the Invisible</span>
</div>
<div class="status-bar">
<div class="status-dot inactive" id="scannerDot"></div>
<span id="scannerStatus">STANDBY</span>
</div>
</header>
{% 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">
<div class="strip-stat">
<span class="strip-value" id="stripTowers">0</span>
<span class="strip-label">TOWERS</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripDevices">0</span>
<span class="strip-label">DEVICES</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripRogues">0</span>
<span class="strip-label">ROGUES</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripSignals">0</span>
<span class="strip-label">SIGNALS</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripCrowd">-</span>
<span class="strip-label">CROWD</span>
</div>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot inactive" id="trackingDot"></div>
<span id="trackingStatus">STANDBY</span>
</div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
<button class="strip-btn" onclick="openAnalyticsModal()">
Analytics Overview
</button>
</div>
</div>
<!-- Analytics Overview Modal -->
<div class="analytics-modal-overlay" id="analyticsModal">
<div class="analytics-modal">
<div class="analytics-modal-header">
<div class="analytics-modal-title">Analytics Overview</div>
<button class="analytics-modal-close" onclick="closeAnalyticsModal()">&times;</button>
</div>
<div class="analytics-modal-content">
<div class="analytics-grid">
<!-- Velocity Tracking Card -->
<div class="analytics-card">
<div class="analytics-card-header">
<div class="analytics-card-icon">📍</div>
<div class="analytics-card-title">Velocity Tracking</div>
</div>
<div class="analytics-card-description">
Track device movement by analyzing Timing Advance transitions and cell handovers.
Estimates velocity and direction based on TA delta and cell sector patterns.
</div>
<div class="analytics-card-stats">
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="velocityDevices">0</div>
<div class="analytics-card-stat-label">Devices Tracked</div>
</div>
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="velocityAvg">- km/h</div>
<div class="analytics-card-stat-label">Avg Velocity</div>
</div>
</div>
</div>
<!-- Crowd Density Card -->
<div class="analytics-card">
<div class="analytics-card-header">
<div class="analytics-card-icon">👥</div>
<div class="analytics-card-title">Crowd Density</div>
</div>
<div class="analytics-card-description">
Aggregate TMSI pings per cell sector to estimate crowd density.
Visualizes hotspots and congestion patterns across towers.
</div>
<div class="analytics-card-stats">
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="crowdTotal">0</div>
<div class="analytics-card-stat-label">Total Devices</div>
</div>
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="crowdPeak">0</div>
<div class="analytics-card-stat-label">Peak Sector</div>
</div>
</div>
</div>
<!-- Life Patterns Card -->
<div class="analytics-card">
<div class="analytics-card-header">
<div class="analytics-card-icon">📊</div>
<div class="analytics-card-title">Life Patterns</div>
</div>
<div class="analytics-card-description">
Analyze 60-day historical data to identify recurring patterns in device behavior.
Detects work locations, commute routes, and daily routines.
</div>
<div class="analytics-card-stats">
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="patternsFound">0</div>
<div class="analytics-card-stat-label">Patterns Found</div>
</div>
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="patternsConfidence">0%</div>
<div class="analytics-card-stat-label">Confidence</div>
</div>
</div>
</div>
<!-- Neighbor Audit Card -->
<div class="analytics-card">
<div class="analytics-card-header">
<div class="analytics-card-icon">🔍</div>
<div class="analytics-card-title">Neighbor Audit</div>
</div>
<div class="analytics-card-description">
Validate neighbor cell lists against expected network topology.
Detects inconsistencies that may indicate rogue towers.
</div>
<div class="analytics-card-stats">
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="neighborTotal">0</div>
<div class="analytics-card-stat-label">Neighbors</div>
</div>
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="neighborAnomalies">0</div>
<div class="analytics-card-stat-label">Anomalies</div>
</div>
</div>
</div>
<!-- Traffic Correlation Card -->
<div class="analytics-card">
<div class="analytics-card-header">
<div class="analytics-card-icon">📡</div>
<div class="analytics-card-title">Traffic Correlation</div>
</div>
<div class="analytics-card-description">
Correlate uplink and downlink timing to identify communication patterns.
Maps device-to-device interactions and network flows.
</div>
<div class="analytics-card-stats">
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="trafficPairs">0</div>
<div class="analytics-card-stat-label">Paired Flows</div>
</div>
<div class="analytics-card-stat">
<div class="analytics-card-stat-value" id="trafficActive">0</div>
<div class="analytics-card-stat-label">Active Now</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<main class="dashboard">
<!-- Left Sidebar -->
<div class="left-sidebar">
<!-- Signal Source Panel -->
<div class="panel signal-source-panel">
<div class="panel-header">
<span>SIGNAL SOURCE</span>
<div class="panel-indicator"></div>
</div>
<div class="signal-source-content">
<div class="form-group">
<label>SDR Device</label>
<select id="deviceSelect" title="SDR device for GSM scanning">
<option value="0">Detecting devices...</option>
</select>
</div>
</div>
</div>
<!-- Region Selector Panel -->
<div class="panel region-panel">
<div class="panel-header">
<span>REGION / BANDS</span>
<div class="panel-indicator"></div>
</div>
<div class="region-buttons">
<button class="region-btn active" data-region="americas" onclick="selectRegion('americas')">
Americas (850/1900 MHz)
</button>
<button class="region-btn" data-region="europe" onclick="selectRegion('europe')">
Europe (900/1800 MHz)
</button>
<button class="region-btn" data-region="asia" onclick="selectRegion('asia')">
Asia (900/1800 MHz)
</button>
</div>
</div>
<!-- Advanced Analysis -->
<div class="panel advanced-analysis-panel">
<div class="panel-header">
<span>ADVANCED ANALYSIS</span>
<div class="panel-indicator" style="background: var(--accent-cyan);"></div>
</div>
<div class="analysis-buttons">
<button class="analysis-btn" onclick="showVelocityTracking()">
Velocity Tracking
</button>
<button class="analysis-btn" onclick="showCrowdDensity()">
Crowd Density
</button>
<button class="analysis-btn" onclick="showLifePatterns()">
Life Patterns
</button>
<button class="analysis-btn" onclick="showNeighborAudit()">
Neighbor Audit
</button>
<button class="analysis-btn" onclick="showTrafficCorrelation()">
Traffic Correlation
</button>
</div>
<div id="analysisResults" class="analysis-results" style="display: none;">
<div class="analysis-header">
<span id="analysisTitle">Analysis Results</span>
<button onclick="closeAnalysis()" style="background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 0; font-size: 18px;">&times;</button>
</div>
<div id="analysisContent" class="analysis-content">
<!-- Dynamic content will be loaded here -->
</div>
</div>
</div>
</div>
<!-- Main Display (Map) -->
<div class="main-display">
<div id="scanProgress" class="scan-progress-overlay" style="display:none;">
<div class="scan-progress-inner">
<div class="scan-progress-header">
<span class="scan-progress-icon"></span>
<span id="scanStatusText">Scanning...</span>
<span id="scanPercentText" class="scan-percent">0%</span>
</div>
<div class="scan-progress-track">
<div id="scanProgressBar" class="scan-progress-fill"></div>
</div>
</div>
</div>
<div id="monitorStatus" class="monitor-status-overlay" style="display:none;">
<div class="monitor-status-inner">
<div class="monitor-status-row">
<span class="monitor-pulse"></span>
<span class="monitor-label">MONITORING</span>
<span class="monitor-arfcn" id="monitorArfcn">ARFCN ---</span>
<span class="monitor-elapsed" id="monitorElapsed">00:00</span>
</div>
<div class="monitor-stats-row">
<span class="monitor-stat"><span id="monitorPackets">0</span> packets</span>
<span class="monitor-stat-sep"></span>
<span class="monitor-stat"><span id="monitorDevices">0</span> devices</span>
<span class="monitor-stat-sep"></span>
<span class="monitor-stat monitor-listening" id="monitorActivity">Listening...</span>
</div>
</div>
</div>
<div id="gsmMap"></div>
</div>
<!-- Right Sidebar -->
<div class="right-sidebar">
<!-- Selected Tower Info -->
<div class="panel selected-tower-panel">
<div class="panel-header">
<span>SELECTED TOWER</span>
<div class="panel-indicator"></div>
</div>
<div id="selectedTowerInfo">
<div class="no-selection">
<div style="font-size: 24px; margin-bottom: 8px;">📡</div>
<div>Select a tower from the list</div>
</div>
</div>
</div>
<!-- Tracked Towers -->
<div class="panel tracked-list">
<div class="panel-header">
<span>TRACKED TOWERS</span>
<div class="panel-indicator"></div>
</div>
<div class="tracked-list-content" id="towersList">
<div class="no-data">
<div>No towers detected</div>
<div style="font-size: 9px; margin-top: 5px;">Start scanner to begin</div>
</div>
</div>
</div>
<!-- Tracked Devices -->
<div class="panel tracked-list">
<div class="panel-header">
<span>TRACKED DEVICES</span>
<div class="panel-indicator"></div>
</div>
<div class="tracked-list-content" id="devicesList">
<div class="no-data">
<div>No devices tracked</div>
<div style="font-size: 9px; margin-top: 5px;">Devices will appear here</div>
</div>
</div>
</div>
<!-- Alerts -->
<div class="panel alerts-panel">
<div class="panel-header">
<span>ALERTS</span>
<div class="panel-indicator" style="background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red);"></div>
</div>
<div class="panel-content" id="alertsList">
<div class="no-data">
<div>No alerts</div>
</div>
</div>
</div>
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<!-- Location Group -->
<div class="control-group">
<span class="control-group-label">GPS LOCATION</span>
<div class="control-group-items">
<input type="text" id="obsLat" value="51.5074" style="width: 90px;" placeholder="Latitude" title="Observer Latitude">
<input type="text" id="obsLon" value="-0.1278" style="width: 90px;" placeholder="Longitude" title="Observer Longitude">
<span id="gpsIndicator" class="gps-indicator" style="display: none;">
<span class="gps-dot"></span> GPS
</span>
</div>
</div>
<!-- GSM Scanner Group -->
<div class="control-group">
<span class="control-group-label">GSM SCANNER</span>
<div class="control-group-items">
<select id="scannerRegion" title="GSM frequency region" onchange="updateBandSelector()">
<option value="Americas">Americas</option>
<option value="Europe" selected>Europe</option>
<option value="Asia">Asia</option>
</select>
<select id="bandSelector" title="Select band to scan">
<!-- Dynamically populated based on region -->
</select>
<button class="start-btn" id="startBtn" onclick="toggleScanner()">START</button>
</div>
</div>
</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
// ============================================
let gsmMap = null;
let towers = {}; // key -> tower data
let devices = {}; // key -> device data
let towerMarkers = {}; // key -> L.marker
let deviceMarkers = {}; // key -> L.marker
let sectorArcs = {}; // key -> L.polygon
let taRings = {}; // key -> array of L.circle
let selectedTowerKey = null;
let eventSource = null;
let isScanning = false;
let currentRegion = 'americas';
// Statistics
let stats = {
totalTowers: 0,
totalDevices: 0,
totalRogues: 0,
totalSignals: 0
};
// XSS protection: Escape HTML special characters
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Band configurations by region
const BAND_CONFIG = {
'Europe': [
{ name: 'EGSM900', label: 'EGSM900 (925-960 MHz)', freq: '925-960 MHz', common: true, recommended: true },
{ name: 'DCS1800', label: 'DCS1800 (1805-1880 MHz)', freq: '1805-1880 MHz', common: true, recommended: false },
{ name: 'GSM850', label: 'GSM850 (869-894 MHz)', freq: '869-894 MHz', common: false, recommended: false },
{ name: 'GSM800', label: 'GSM800 (832-862 MHz)', freq: '832-862 MHz', common: false, recommended: false }
],
'Americas': [
{ name: 'GSM850', label: 'GSM850 (869-894 MHz)', freq: '869-894 MHz', common: true, recommended: true },
{ name: 'PCS1900', label: 'PCS1900 (1930-1990 MHz)', freq: '1930-1990 MHz', common: true, recommended: true }
],
'Asia': [
{ name: 'EGSM900', label: 'EGSM900 (925-960 MHz)', freq: '925-960 MHz', common: true, recommended: true },
{ name: 'DCS1800', label: 'DCS1800 (1805-1880 MHz)', freq: '1805-1880 MHz', common: true, recommended: true }
]
};
// ============================================
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', function() {
initMap();
loadObserverLocation();
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() {
// Initialize Leaflet map with dark theme
gsmMap = L.map('gsmMap', {
center: [51.5074, -0.1278],
zoom: 13,
zoomControl: true
});
// CartoDB Dark Matter tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(gsmMap);
console.log('[GSM SPY] Map initialized');
}
function loadObserverLocation() {
// Load from shared observer location if available
if (window.INTERCEPT_SHARED_OBSERVER_LOCATION) {
const loc = window.INTERCEPT_SHARED_OBSERVER_LOCATION;
if (loc.lat && loc.lon) {
document.getElementById('obsLat').value = loc.lat;
document.getElementById('obsLon').value = loc.lon;
gsmMap.setView([loc.lat, loc.lon], 13);
}
if (loc.gps_enabled) {
document.getElementById('gpsIndicator').style.display = 'flex';
}
}
}
function startUtcClock() {
function updateClock() {
const now = new Date();
const utc = now.toISOString().slice(11, 19);
document.getElementById('utcTime').textContent = utc + ' UTC';
}
setInterval(updateClock, 1000);
updateClock();
}
async function initDeviceSelector() {
try {
const response = await fetch('/devices');
const devices = await response.json();
const deviceSelect = document.getElementById('deviceSelect');
deviceSelect.innerHTML = '';
if (!devices || devices.length === 0) {
deviceSelect.innerHTML = '<option value="0">No SDR devices detected</option>';
console.warn('[GSM SPY] No SDR devices detected');
return;
}
// Populate dropdown with detected devices
devices.forEach(device => {
const option = document.createElement('option');
option.value = device.index;
option.textContent = `${device.name} (${device.sdr_type})`;
if (device.serial) {
option.textContent += ` - ${device.serial}`;
}
deviceSelect.appendChild(option);
});
console.log(`[GSM SPY] Detected ${devices.length} SDR device(s)`);
} catch (error) {
console.error('[GSM SPY] Error fetching devices:', error);
const deviceSelect = document.getElementById('deviceSelect');
deviceSelect.innerHTML = '<option value="0">Error detecting devices</option>';
}
}
// ============================================
// BAND SELECTOR
// ============================================
function updateBandSelector() {
const region = document.getElementById('scannerRegion').value;
const bands = BAND_CONFIG[region] || [];
const selector = document.getElementById('bandSelector');
selector.innerHTML = '';
// Add "All Bands" option
const allOption = document.createElement('option');
allOption.value = 'ALL';
allOption.textContent = 'All Bands (Slower)';
selector.appendChild(allOption);
// Add individual bands
bands.forEach(band => {
const option = document.createElement('option');
option.value = band.name;
option.textContent = band.label;
// Select first primary band by default
if (band.recommended && selector.value !== 'ALL' && !selector.querySelector('option:checked')) {
option.selected = true;
}
selector.appendChild(option);
});
// If no band selected, select first primary band
if (!selector.value || selector.value === 'ALL') {
const firstPrimary = bands.find(b => b.recommended);
if (firstPrimary) {
selector.value = firstPrimary.name;
}
}
}
function getSelectedBands() {
const selector = document.getElementById('bandSelector');
const selected = selector.value;
if (selected === 'ALL') {
// Return all bands for the region
const region = document.getElementById('scannerRegion').value;
const bands = BAND_CONFIG[region] || [];
return bands.map(b => b.name);
} else {
// Return single selected band
return [selected];
}
}
// ============================================
// SCANNER CONTROL
// ============================================
function toggleScanner() {
if (isScanning) {
stopScanner();
} else {
startScanner();
}
}
async function startScanner() {
const device = parseInt(document.getElementById('deviceSelect').value) || 0;
const region = document.getElementById('scannerRegion').value;
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
const selectedBands = getSelectedBands();
if (isNaN(lat) || isNaN(lon)) {
alert('Please enter valid GPS coordinates');
return;
}
if (selectedBands.length === 0) {
alert('Please select at least one band to scan');
return;
}
// Start backend scanner
try {
const response = await fetch('/gsm_spy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device: device,
region: region,
bands: selectedBands, // Send selected bands
lat: lat,
lon: lon
})
});
if (!response.ok) {
const error = await response.json();
if (response.status === 409 && error.error_type === 'DEVICE_BUSY') {
alert(`Device Conflict: ${error.error}\n\nStop the other mode before starting GSM scanner.`);
} else {
alert(`Error: ${error.error || 'Failed to start GSM scanner'}`);
}
return;
}
const data = await response.json();
if (data.status === 'started') {
isScanning = true;
updateScannerUI(true);
// Disable controls during scanning
document.getElementById('deviceSelect').disabled = true;
document.getElementById('scannerRegion').disabled = true;
startEventStream();
console.log('[GSM SPY] Scanner started');
} else {
alert('Failed to start scanner: ' + (data.error || 'Unknown error'));
}
} catch (error) {
console.error('[GSM SPY] Error starting scanner:', error);
alert('Error starting scanner');
}
}
function stopScanner() {
fetch('/gsm_spy/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
isScanning = false;
updateScannerUI(false);
// Re-enable controls
document.getElementById('deviceSelect').disabled = false;
document.getElementById('scannerRegion').disabled = false;
if (eventSource) {
eventSource.close();
eventSource = null;
}
document.getElementById('scanProgress').style.display = 'none';
hideMonitorStatus();
console.log('[GSM SPY] Scanner stopped');
})
.catch(error => {
console.error('[GSM SPY] Error stopping scanner:', error);
});
}
function updateScannerUI(active) {
const startBtn = document.getElementById('startBtn');
const scannerDot = document.getElementById('scannerDot');
const scannerStatus = document.getElementById('scannerStatus');
const trackingDot = document.getElementById('trackingDot');
const trackingStatus = document.getElementById('trackingStatus');
if (active) {
startBtn.textContent = 'STOP';
startBtn.classList.add('active');
scannerDot.classList.remove('inactive');
scannerStatus.textContent = 'SCANNING';
trackingDot.classList.remove('inactive');
trackingStatus.textContent = 'ACTIVE';
} else {
startBtn.textContent = 'START';
startBtn.classList.remove('active');
scannerDot.classList.add('inactive');
scannerStatus.textContent = 'STANDBY';
trackingDot.classList.add('inactive');
trackingStatus.textContent = 'STANDBY';
}
}
// ============================================
// EVENT STREAM (SSE)
// ============================================
function startEventStream() {
if (eventSource) {
eventSource.close();
}
console.log('[GSM SPY] Opening EventSource to /gsm_spy/stream');
eventSource = new EventSource('/gsm_spy/stream');
eventSource.onopen = function() {
console.log('[GSM SPY] EventSource connected');
};
eventSource.onmessage = function(e) {
try {
console.log('[GSM SPY] SSE raw:', e.data.substring(0, 200));
const data = JSON.parse(e.data);
if (data.type === 'keepalive') {
return;
}
console.log('[GSM SPY] SSE event type:', data.type, 'keys:', Object.keys(data).join(','));
if (data.type === 'tower' || data.type === 'tower_update') {
updateTower(data);
} else if (data.type === 'device') {
updateDevice(data);
} else if (data.type === 'rogue_alert') {
addRogueAlert(data);
} else if (data.type === 'stats') {
updateStats(data);
} else if (data.type === 'progress') {
updateScanProgress(data.percent, data.scan);
} else if (data.type === 'status') {
updateScanStatus(data.message);
} else if (data.type === 'scan_complete') {
updateScanStatus('Scan #' + data.scan + ' complete (' + data.towers_found + ' towers, ' + data.duration + 's)');
document.getElementById('scanProgressBar').style.width = '100%';
} else if (data.type === 'auto_monitor_started') {
showMonitorStatus(data.arfcn);
console.log('[GSM SPY] Auto-monitor started on ARFCN', data.arfcn);
} else if (data.type === 'monitor_heartbeat') {
updateMonitorStatus(data.elapsed, data.packets, data.devices);
} else if (data.type === 'error') {
console.error('[GSM SPY] Server error:', data.message);
updateScanStatus('Error: ' + data.message);
} else if (data.type === 'disconnected') {
console.warn('[GSM SPY] Server disconnected stream');
hideMonitorStatus();
}
} catch (error) {
console.error('[GSM SPY] Error parsing event:', error, 'raw:', e.data);
}
};
eventSource.onerror = function(e) {
console.error('[GSM SPY] EventSource error, readyState:', eventSource.readyState);
if (eventSource.readyState === EventSource.CLOSED) {
console.log('[GSM SPY] EventSource closed, reconnecting in 3s...');
setTimeout(startEventStream, 3000);
}
};
}
// ============================================
// GSM ICON DEFINITIONS - High Quality Vector Icons
// ============================================
const GSM_ICONS = {
// Cell tower icon with detailed antenna structure
tower: 'M12 1L10.5 2.5V7H8V9H10V11H8V13H10V15H8V17H10V19H8V21H16V19H14V17H16V15H14V13H16V11H14V9H16V7H13.5V2.5L12 1M12 3.5L12.5 4V7H11.5V4L12 3.5M7 9H5V21H7V9M19 9H17V21H19V9M4 11H2V21H4V11M22 11H20V21H22V11M3 13H1V21H3V13M23 13H21V21H23V13Z',
// Smartphone icon with detailed screen and body
device: 'M17 1H7C5.89 1 5 1.89 5 3V21C5 22.1 5.9 23 7 23H17C18.1 23 19 22.1 19 21V3C19 1.89 18.1 1 17 1M17 19H7V5H17V19M12 21C11.45 21 11 20.55 11 20C11 19.45 11.45 19 12 19C12.55 19 13 19.45 13 20C13 20.55 12.55 21 12 21Z'
};
// Create marker icon with SVG
function createGSMMarkerIcon(iconType, color, isSelected = false, isRogue = false) {
const path = GSM_ICONS[iconType] || GSM_ICONS.tower;
const size = iconType === 'tower' ? 24 : 20;
const glowColor = isSelected ? 'rgba(255,255,255,0.9)' : color;
const glowSize = isSelected ? '8px' : (isRogue ? '6px' : '4px');
const pulseRing = isRogue && !isSelected ?
'<div class="rogue-pulse-ring"></div>' : '';
const selectionRing = isSelected ?
'<div class="selection-ring"></div>' : '';
return L.divIcon({
className: `gsm-marker gsm-${iconType}${isSelected ? ' selected' : ''}${isRogue ? ' rogue' : ''}`,
html: `${pulseRing}${selectionRing}<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="color: ${color}; filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
<path fill="currentColor" d="${path}"/>
</svg>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
}
// ============================================
// TOWER HANDLING
// ============================================
function updateTower(data) {
const key = `${data.mcc}-${data.mnc}-${data.lac}-${data.cid}`;
console.log(`[GSM SPY] updateTower: key=${key} CID=${data.cid} signal=${data.signal_strength} lat=${data.lat} lon=${data.lon}`);
towers[key] = data;
// Always update list and stats (regardless of coordinates)
updateTowersList();
stats.totalTowers = Object.keys(towers).length;
stats.totalRogues = Object.values(towers).filter(t => t.rogue).length;
updateStatsDisplay();
// Validate coordinates before creating map marker
if (!data.lat || !data.lon || isNaN(parseFloat(data.lat)) || isNaN(parseFloat(data.lon))) {
console.log(`[GSM SPY] Tower ${data.cid} pending geocoding (status: ${data.status || 'unknown'}), list updated`);
return;
}
const color = data.rogue ? '#e25d5d' : '#38c180';
const isSelected = key === selectedTowerKey;
// Create or update marker
if (!towerMarkers[key]) {
// Create new marker with vector icon
const marker = L.marker([data.lat, data.lon], {
icon: createGSMMarkerIcon('tower', color, isSelected, data.rogue)
});
marker.on('click', function() {
selectTower(key);
});
marker.bindPopup(`
<strong>${data.rogue ? '⚠ ROGUE ' : ''}Tower ${data.cid}</strong><br>
MCC: ${data.mcc} MNC: ${data.mnc}<br>
LAC: ${data.lac} ARFCN: ${data.arfcn}
`);
marker.addTo(gsmMap);
towerMarkers[key] = marker;
// Draw sector arc
drawSectorArc(key, data);
// Draw TA rings
drawTARings(key, data);
} else {
// Update existing marker
const marker = towerMarkers[key];
marker.setLatLng([data.lat, data.lon]);
// Update icon if rogue status or selection changed
marker.setIcon(createGSMMarkerIcon('tower', color, isSelected, data.rogue));
}
}
function drawSectorArc(key, tower) {
// Calculate sector direction based on CID modulo 3
const sectorIndex = tower.cid % 3;
const startAngle = sectorIndex * 120;
const endAngle = startAngle + 120;
const radius = 1000; // meters
const wedge = createWedge([tower.lat, tower.lon], radius, startAngle, endAngle);
const arc = L.polygon(wedge, {
color: '#4aa3ff',
fillColor: '#4aa3ff',
fillOpacity: 0.1,
weight: 1,
opacity: 0.3
});
arc.addTo(gsmMap);
sectorArcs[key] = arc;
}
function drawTARings(key, tower) {
// Draw concentric circles for Timing Advance rings
// TA range: 0-63, each unit = ~554 meters
const rings = [];
const taMax = 63;
const metersPerTa = 554;
for (let ta = 15; ta <= taMax; ta += 15) {
const radius = ta * metersPerTa;
const circle = L.circle([tower.lat, tower.lon], {
radius: radius,
color: '#38c180',
weight: 1,
fillOpacity: 0,
opacity: 0.2
});
circle.addTo(gsmMap);
rings.push(circle);
}
taRings[key] = rings;
}
function createWedge(center, radius, startAngle, endAngle) {
// Create wedge polygon for sector arc
const points = [center];
const segments = 32;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + (endAngle - startAngle) * i / segments;
const rad = angle * Math.PI / 180;
const latOffset = (radius / 111320) * Math.cos(rad);
const lonOffset = (radius / (111320 * Math.cos(center[0] * Math.PI / 180))) * Math.sin(rad);
points.push([center[0] + latOffset, center[1] + lonOffset]);
}
points.push(center);
return points;
}
function selectTower(key) {
try {
console.log(`[GSM SPY] selectTower: ${key}`);
const prevSelected = selectedTowerKey;
selectedTowerKey = key;
const tower = towers[key];
if (!tower) {
console.warn(`[GSM SPY] Tower not found for key: ${key}`);
return;
}
// Update marker icons for both previous and new selection
[prevSelected, key].forEach(towerKey => {
if (towerKey && towerMarkers[towerKey] && towers[towerKey]) {
const t = towers[towerKey];
const color = t.rogue ? '#e25d5d' : '#38c180';
const isSelected = towerKey === selectedTowerKey;
towerMarkers[towerKey].setIcon(createGSMMarkerIcon('tower', color, isSelected, t.rogue));
}
});
// Build info rows
const signalVal = tower.signal_strength != null ? escapeHtml(tower.signal_strength) : 'N/A';
const locationVal = tower.lat != null ? parseFloat(tower.lat).toFixed(6) + ', ' + parseFloat(tower.lon).toFixed(6) : 'Pending geocoding';
const timeVal = tower.timestamp ? new Date(tower.timestamp).toLocaleTimeString() : 'Unknown';
const operatorVal = tower.operator ? escapeHtml(tower.operator) : '';
const freqVal = tower.frequency ? escapeHtml(tower.frequency) + ' MHz' : '';
const statusVal = tower.status === 'pending' ? 'Pending geocoding' : (tower.source === 'cache' || tower.source === 'api' ? 'Resolved' : '');
// Update selected tower panel
const infoDiv = document.getElementById('selectedTowerInfo');
infoDiv.innerHTML = `
<div class="tower-info">
<div class="tower-info-row">
<span class="tower-info-label">Cell ID</span>
<span class="tower-info-value">${escapeHtml(tower.cid)} ${tower.rogue ? '<span class="tower-rogue-badge">ROGUE</span>' : ''}</span>
</div>
${operatorVal ? `<div class="tower-info-row"><span class="tower-info-label">Operator</span><span class="tower-info-value">${operatorVal}</span></div>` : ''}
<div class="tower-info-row">
<span class="tower-info-label">MCC / MNC</span>
<span class="tower-info-value">${escapeHtml(tower.mcc)} / ${escapeHtml(tower.mnc)}</span>
</div>
<div class="tower-info-row">
<span class="tower-info-label">LAC</span>
<span class="tower-info-value">${escapeHtml(tower.lac)}</span>
</div>
<div class="tower-info-row">
<span class="tower-info-label">ARFCN</span>
<span class="tower-info-value">${escapeHtml(tower.arfcn)}${freqVal ? ' (' + freqVal + ')' : ''}</span>
</div>
<div class="tower-info-row">
<span class="tower-info-label">Signal</span>
<span class="tower-info-value">${signalVal} dBm</span>
</div>
<div class="tower-info-row">
<span class="tower-info-label">Location</span>
<span class="tower-info-value">${locationVal}</span>
</div>
<div class="tower-info-row">
<span class="tower-info-label">First Seen</span>
<span class="tower-info-value">${timeVal}</span>
</div>
${statusVal ? `<div class="tower-info-row"><span class="tower-info-label">Status</span><span class="tower-info-value">${statusVal}</span></div>` : ''}
</div>
`;
// Update list selection
updateTowersList();
} catch (error) {
console.error('[GSM SPY] selectTower error:', error);
}
}
function updateScanProgress(percent, scanNum) {
const progressDiv = document.getElementById('scanProgress');
const bar = document.getElementById('scanProgressBar');
const text = document.getElementById('scanPercentText');
progressDiv.style.display = 'block';
bar.style.width = percent + '%';
text.textContent = Math.round(percent) + '% (Scan #' + scanNum + ')';
}
function updateScanStatus(message) {
const progressDiv = document.getElementById('scanProgress');
const statusText = document.getElementById('scanStatusText');
progressDiv.style.display = 'block';
statusText.textContent = message;
}
let monitorStartTime = null;
let monitorTimerInterval = null;
function showMonitorStatus(arfcn) {
// Hide scan progress, show monitor status
document.getElementById('scanProgress').style.display = 'none';
const overlay = document.getElementById('monitorStatus');
overlay.style.display = 'block';
document.getElementById('monitorArfcn').textContent = 'ARFCN ' + arfcn;
document.getElementById('monitorPackets').textContent = '0';
document.getElementById('monitorDevices').textContent = '0';
document.getElementById('monitorActivity').textContent = 'Listening...';
// Start local elapsed timer for smooth updates between heartbeats
monitorStartTime = Date.now();
if (monitorTimerInterval) clearInterval(monitorTimerInterval);
monitorTimerInterval = setInterval(function() {
const elapsed = Math.floor((Date.now() - monitorStartTime) / 1000);
document.getElementById('monitorElapsed').textContent = formatElapsed(elapsed);
}, 1000);
}
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 = 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() {
document.getElementById('monitorStatus').style.display = 'none';
if (monitorTimerInterval) {
clearInterval(monitorTimerInterval);
monitorTimerInterval = null;
}
monitorStartTime = null;
}
function formatElapsed(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
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 style="font-size: 10px; margin-top: 5px;">Start scanner to begin</div></div>';
return;
}
let html = '';
for (const [key, tower] of Object.entries(towers)) {
const selected = key === selectedTowerKey ? 'selected' : '';
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">
<span class="list-item-id">CID ${escapeHtml(tower.cid)}</span>
<span class="list-item-meta">${metaText}</span>
${tower.rogue ? '<span class="rogue-indicator"></span>' : ''}
</div>
<div class="list-item-details">
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>
`;
}
listDiv.innerHTML = html;
}
// ============================================
// DEVICE HANDLING
// ============================================
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) {
console.warn('[GSM SPY] Device has no coordinates, skipping map marker:', key);
updateDevicesList();
return;
}
// Create device marker with vector icon
const marker = L.marker([data.lat, data.lon], {
icon: createGSMMarkerIcon('device', '#00d9ff', false, false)
});
marker.bindPopup(`
<strong>Device</strong><br>
${data.imsi ? 'IMSI: ' + data.imsi : 'TMSI: ' + data.tmsi}<br>
Tower: ${data.cid}
`);
marker.addTo(gsmMap);
deviceMarkers[key] = marker;
// Fade out and remove marker after 4 seconds
setTimeout(() => {
if (deviceMarkers[key]) {
const iconElement = deviceMarkers[key].getElement();
if (iconElement) {
iconElement.classList.add('device-fade-out');
}
}
}, 4000);
// Remove marker after fade completes
setTimeout(() => {
if (deviceMarkers[key]) {
gsmMap.removeLayer(deviceMarkers[key]);
delete deviceMarkers[key];
}
}, 5000);
// Update devices list
updateDevicesList();
// Update stats
stats.totalDevices = Object.keys(devices).length;
updateStatsDisplay();
}
function updateDevicesList() {
const listDiv = document.getElementById('devicesList');
if (Object.keys(devices).length === 0) {
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 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="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="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>
`;
}
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
// ============================================
function addRogueAlert(data) {
const listDiv = document.getElementById('alertsList');
if (listDiv.querySelector('.no-data')) {
listDiv.innerHTML = '';
}
const alertItem = document.createElement('div');
alertItem.className = 'alert-item';
alertItem.innerHTML = `
<div class="alert-time">${new Date(data.timestamp).toLocaleTimeString()}</div>
<div class="alert-message">
⚠ ROGUE TOWER DETECTED<br>
CID ${escapeHtml(data.cid)} | MCC ${escapeHtml(data.mcc)} MNC ${escapeHtml(data.mnc)} | ${escapeHtml(data.reason || 'Unknown threat')}
</div>
`;
listDiv.insertBefore(alertItem, listDiv.firstChild);
// Keep only last 20 alerts
while (listDiv.children.length > 20) {
listDiv.removeChild(listDiv.lastChild);
}
}
// ============================================
// STATS
// ============================================
function updateStats(data) {
if (data.total_signals !== undefined) {
stats.totalSignals = data.total_signals;
}
updateStatsDisplay();
}
function updateStatsDisplay() {
document.getElementById('stripTowers').textContent = stats.totalTowers;
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 : '-';
}
// ============================================
// REGION SELECTION
// ============================================
function selectRegion(region) {
currentRegion = region;
// Capitalize first letter to match API expectations
const regionCapitalized = region.charAt(0).toUpperCase() + region.slice(1);
// Update UI
document.querySelectorAll('.region-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`.region-btn[data-region="${region}"]`).classList.add('active');
// Update scanner region select
document.getElementById('scannerRegion').value = regionCapitalized;
console.log('[GSM SPY] Region selected:', regionCapitalized);
}
// ============================================
// ANALYTICS OVERVIEW MODAL
// ============================================
function openAnalyticsModal() {
document.getElementById('analyticsModal').classList.add('active');
updateAnalyticsStats();
}
function closeAnalyticsModal() {
document.getElementById('analyticsModal').classList.remove('active');
}
// Close modal when clicking outside
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('analyticsModal');
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeAnalyticsModal();
}
});
// Close modals on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (document.getElementById('deviceDetailModal').classList.contains('active')) {
closeDeviceDetail();
} else if (modal.classList.contains('active')) {
closeAnalyticsModal();
}
}
});
});
async function updateAnalyticsStats() {
try {
// Velocity Tracking stats
const velocityResp = await fetch('/gsm_spy/velocity?minutes=60');
const velocityData = await velocityResp.json();
if (velocityData && velocityData.length > 0) {
document.getElementById('velocityDevices').textContent = velocityData.length;
const avgVelocity = velocityData.reduce((sum, item) => sum + item.estimated_velocity, 0) / velocityData.length;
document.getElementById('velocityAvg').textContent = (avgVelocity * 3.6).toFixed(1) + ' km/h';
}
// Crowd Density stats
const crowdResp = await fetch('/gsm_spy/crowd_density?hours=24');
const crowdData = await crowdResp.json();
if (crowdData && crowdData.length > 0) {
const totalDevices = crowdData.reduce((sum, item) => sum + item.device_count, 0);
const peakSector = Math.max(...crowdData.map(item => item.device_count));
document.getElementById('crowdTotal').textContent = totalDevices;
document.getElementById('crowdPeak').textContent = peakSector;
}
// Life Patterns stats
const patternsResp = await fetch('/gsm_spy/life_patterns?days=60');
const patternsData = await patternsResp.json();
if (patternsData && patternsData.length > 0) {
document.getElementById('patternsFound').textContent = patternsData.length;
// Calculate average confidence if available
if (patternsData[0].confidence) {
const avgConfidence = patternsData.reduce((sum, item) => sum + item.confidence, 0) / patternsData.length;
document.getElementById('patternsConfidence').textContent = avgConfidence.toFixed(0) + '%';
}
}
// Neighbor Audit stats
const neighborResp = await fetch('/gsm_spy/neighbor_audit');
const neighborData = await neighborResp.json();
if (neighborData) {
document.getElementById('neighborTotal').textContent = neighborData.total_neighbors || 0;
document.getElementById('neighborAnomalies').textContent = neighborData.anomalies || 0;
}
// Traffic Correlation stats
const trafficResp = await fetch('/gsm_spy/traffic_correlation?minutes=30');
const trafficData = await trafficResp.json();
if (trafficData && trafficData.length > 0) {
document.getElementById('trafficPairs').textContent = trafficData.length;
const activeNow = trafficData.filter(item => item.is_active).length;
document.getElementById('trafficActive').textContent = activeNow;
}
} catch (error) {
console.error('Error updating analytics stats:', error);
}
}
// ============================================
// ADVANCED ANALYSIS FEATURES
// ============================================
function closeAnalysis() {
document.getElementById('analysisResults').style.display = 'none';
}
async function showVelocityTracking() {
const resultsDiv = document.getElementById('analysisResults');
const contentDiv = document.getElementById('analysisContent');
document.getElementById('analysisTitle').textContent = 'Velocity Tracking';
try {
const response = await fetch('/gsm_spy/velocity?minutes=60');
const data = await response.json();
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="no-data">No velocity data available</div>';
} else {
let html = '<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 10px;">Last 60 minutes</div>';
data.slice(0, 10).forEach(item => {
const velocity_kmh = (item.estimated_velocity * 3.6).toFixed(2);
html += `
<div class="analysis-device-item">
<div style="font-weight: 600; color: var(--accent-cyan);">${escapeHtml(item.device_id)}</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Velocity:</span>
<span class="analysis-stat-value">${escapeHtml(velocity_kmh)} km/h</span>
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">TA Change:</span>
<span class="analysis-stat-value">${escapeHtml(String(item.prev_ta))}${escapeHtml(String(item.curr_ta))}</span>
</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 4px;">${new Date(item.timestamp).toLocaleString()}</div>
</div>
`;
});
contentDiv.innerHTML = html;
}
resultsDiv.style.display = 'block';
} catch (error) {
console.error('Error fetching velocity data:', error);
contentDiv.innerHTML = '<div class="analysis-warning">Error loading velocity data</div>';
resultsDiv.style.display = 'block';
}
}
async function showCrowdDensity() {
const resultsDiv = document.getElementById('analysisResults');
const contentDiv = document.getElementById('analysisContent');
document.getElementById('analysisTitle').textContent = 'Crowd Density';
try {
const response = await fetch('/gsm_spy/crowd_density?hours=1');
const data = await response.json();
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="no-data">No crowd data available</div>';
} else {
let html = '<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 10px;">Last 1 hour</div>';
data.forEach(item => {
const densityColor = item.density_level === 'high' ? 'var(--accent-red)' :
item.density_level === 'medium' ? 'var(--accent-yellow)' : 'var(--accent-green)';
html += `
<div class="analysis-device-item" style="border-left-color: ${densityColor};">
<div style="font-weight: 600; color: var(--accent-cyan);">Cell ${escapeHtml(String(item.cid))}</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Unique Devices:</span>
<span class="analysis-stat-value">${escapeHtml(String(item.unique_devices))}</span>
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Total Pings:</span>
<span class="analysis-stat-value">${escapeHtml(String(item.total_pings))}</span>
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Density:</span>
<span class="analysis-stat-value" style="color: ${densityColor}; text-transform: uppercase;">${escapeHtml(item.density_level)}</span>
</div>
</div>
`;
});
contentDiv.innerHTML = html;
}
resultsDiv.style.display = 'block';
} catch (error) {
console.error('Error fetching crowd density:', error);
contentDiv.innerHTML = '<div class="analysis-warning">Error loading crowd density data</div>';
resultsDiv.style.display = 'block';
}
}
async function showLifePatterns() {
const resultsDiv = document.getElementById('analysisResults');
const contentDiv = document.getElementById('analysisContent');
document.getElementById('analysisTitle').textContent = 'Life Patterns';
// Prompt for device ID
const deviceId = prompt('Enter device IMSI or TMSI:');
if (!deviceId) return;
try {
const response = await fetch(`/gsm_spy/life_patterns?device_id=${encodeURIComponent(deviceId)}`);
const data = await response.json();
if (data.error) {
contentDiv.innerHTML = `<div class="analysis-warning">${escapeHtml(data.error)}</div>`;
} else if (data.regular_locations && data.regular_locations.length > 0) {
let html = `
<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 10px;">
${escapeHtml(String(data.total_observations))} total observations
</div>
<div style="font-weight: 600; margin-bottom: 8px;">Regular Locations:</div>
`;
data.regular_locations.forEach(loc => {
html += `
<div class="analysis-device-item">
<div style="font-weight: 600; color: var(--accent-cyan);">Cell ${escapeHtml(String(loc.cid))}</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Typical Time:</span>
<span class="analysis-stat-value">${escapeHtml(loc.typical_time)}</span>
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Frequency:</span>
<span class="analysis-stat-value">${escapeHtml(String(loc.frequency))} times</span>
</div>
</div>
`;
});
contentDiv.innerHTML = html;
} else {
contentDiv.innerHTML = '<div class="no-data">No regular patterns detected</div>';
}
resultsDiv.style.display = 'block';
} catch (error) {
console.error('Error fetching life patterns:', error);
contentDiv.innerHTML = '<div class="analysis-warning">Error loading life patterns</div>';
resultsDiv.style.display = 'block';
}
}
async function showNeighborAudit() {
const resultsDiv = document.getElementById('analysisResults');
const contentDiv = document.getElementById('analysisContent');
document.getElementById('analysisTitle').textContent = 'Neighbor Audit';
// Prompt for CID
const cid = prompt('Enter Cell ID (CID) to audit:');
if (!cid) return;
try {
const response = await fetch(`/gsm_spy/neighbor_audit?cid=${encodeURIComponent(cid)}`);
const data = await response.json();
if (data.error) {
contentDiv.innerHTML = `<div class="analysis-warning">${escapeHtml(data.error)}</div>`;
} else {
const statusColor = data.status === 'suspicious' ? 'var(--accent-red)' : 'var(--accent-green)';
let html = `
<div class="analysis-stat">
<span class="analysis-stat-label">Status:</span>
<span class="analysis-stat-value" style="color: ${statusColor}; text-transform: uppercase;">${escapeHtml(data.status)}</span>
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Neighbor Count:</span>
<span class="analysis-stat-value">${escapeHtml(String(data.neighbor_count))}</span>
</div>
`;
if (data.issues && data.issues.length > 0) {
html += '<div style="font-weight: 600; margin: 10px 0 5px 0;">Issues Found:</div>';
data.issues.forEach(issue => {
html += `
<div class="analysis-warning">
<div style="font-weight: 600;">${escapeHtml(issue.type)}</div>
<div>${escapeHtml(issue.message)}</div>
</div>
`;
});
} else {
html += '<div style="color: var(--accent-green); margin-top: 10px;">✓ No issues detected</div>';
}
contentDiv.innerHTML = html;
}
resultsDiv.style.display = 'block';
} catch (error) {
console.error('Error fetching neighbor audit:', error);
contentDiv.innerHTML = '<div class="analysis-warning">Error loading audit data</div>';
resultsDiv.style.display = 'block';
}
}
async function showTrafficCorrelation() {
const resultsDiv = document.getElementById('analysisResults');
const contentDiv = document.getElementById('analysisContent');
document.getElementById('analysisTitle').textContent = 'Traffic Correlation';
// Prompt for CID
const cid = prompt('Enter Cell ID (CID) to analyze:');
if (!cid) return;
try {
const response = await fetch(`/gsm_spy/traffic_correlation?cid=${encodeURIComponent(cid)}&minutes=5`);
const data = await response.json();
if (data.error) {
contentDiv.innerHTML = `<div class="analysis-warning">${escapeHtml(data.error)}</div>`;
} else {
let html = `
<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 10px;">
Last ${escapeHtml(String(data.time_window_minutes))} minutes
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Active Devices:</span>
<span class="analysis-stat-value">${escapeHtml(String(data.active_devices))}</span>
</div>
`;
if (data.correlations && data.correlations.length > 0) {
html += '<div style="font-weight: 600; margin: 10px 0 5px 0;">Device Activity:</div>';
data.correlations.slice(0, 10).forEach(corr => {
const activityColor = corr.activity_level === 'high' ? 'var(--accent-red)' :
corr.activity_level === 'medium' ? 'var(--accent-yellow)' : 'var(--accent-green)';
html += `
<div class="analysis-device-item">
<div style="font-weight: 600; color: var(--accent-cyan);">${escapeHtml(corr.device_id)}</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Burst Count:</span>
<span class="analysis-stat-value">${escapeHtml(String(corr.burst_count))}</span>
</div>
<div class="analysis-stat">
<span class="analysis-stat-label">Activity:</span>
<span class="analysis-stat-value" style="color: ${activityColor}; text-transform: uppercase;">${escapeHtml(corr.activity_level)}</span>
</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 4px;">TA: ${escapeHtml(String(corr.ta_value))}</div>
</div>
`;
});
} else {
html += '<div class="no-data">No active devices in time window</div>';
}
contentDiv.innerHTML = html;
}
resultsDiv.style.display = 'block';
} catch (error) {
console.error('Error fetching traffic correlation:', error);
contentDiv.innerHTML = '<div class="analysis-warning">Error loading correlation data</div>';
resultsDiv.style.display = 'block';
}
}
</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>
</html>