mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 14:50:00 -07:00
10519 lines
502 KiB
HTML
10519 lines
502 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>iNTERCEPT // See the Invisible</title>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<!-- Leaflet.js for APRS map -->
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||
<!-- Chart.js for signal strength graphs -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/acars.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
|
||
</head>
|
||
|
||
<body>
|
||
<!-- Welcome Page -->
|
||
<div class="welcome-overlay" id="welcomePage">
|
||
<div class="welcome-container">
|
||
<!-- Header Section -->
|
||
<div class="welcome-header">
|
||
<div class="welcome-logo">
|
||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none"
|
||
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
|
||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
|
||
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
|
||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none"
|
||
stroke-linecap="round" class="signal-wave signal-wave-3" />
|
||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none"
|
||
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
|
||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
|
||
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
|
||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none"
|
||
stroke-linecap="round" class="signal-wave signal-wave-3" />
|
||
<circle cx="50" cy="22" r="6" fill="#00ff88" class="logo-dot" />
|
||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
|
||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
</svg>
|
||
</div>
|
||
<div class="welcome-title-block">
|
||
<h1 class="welcome-title">iNTERCEPT</h1>
|
||
<p class="welcome-tagline">// See the Invisible</p>
|
||
<span class="welcome-version">v{{ version }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Grid -->
|
||
<div class="welcome-content">
|
||
<!-- Left: Changelog -->
|
||
<div class="welcome-changelog">
|
||
<h2>What's New</h2>
|
||
{% for release in changelog[:2] %}
|
||
<div class="changelog-release">
|
||
<div class="changelog-version-header">
|
||
<span class="changelog-version">v{{ release.version }}</span>
|
||
<span class="changelog-date">{{ release.date }}</span>
|
||
</div>
|
||
<ul class="changelog-list">
|
||
{% for item in release.highlights %}
|
||
<li>{{ item }}</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Right: Mode Selection -->
|
||
<div class="welcome-modes">
|
||
<h2>Select Mode</h2>
|
||
<div class="mode-grid">
|
||
<button class="mode-card" onclick="selectMode('pager')">
|
||
<span class="mode-icon">📟</span>
|
||
<span class="mode-name">Pager</span>
|
||
<span class="mode-desc">POCSAG/FLEX decoding</span>
|
||
</button>
|
||
<button class="mode-card" onclick="selectMode('sensor')">
|
||
<span class="mode-icon">📡</span>
|
||
<span class="mode-name">433MHz</span>
|
||
<span class="mode-desc">IoT sensor monitoring</span>
|
||
</button>
|
||
<a href="/adsb/dashboard" class="mode-card" style="text-decoration: none;">
|
||
<span class="mode-icon">✈️</span>
|
||
<span class="mode-name">Aircraft</span>
|
||
<span class="mode-desc">ADS-B tracking</span>
|
||
</a>
|
||
<button class="mode-card" onclick="selectMode('wifi')">
|
||
<span class="mode-icon">📶</span>
|
||
<span class="mode-name">WiFi</span>
|
||
<span class="mode-desc">Network reconnaissance</span>
|
||
</button>
|
||
<button class="mode-card" onclick="selectMode('bluetooth')">
|
||
<span class="mode-icon">🔵</span>
|
||
<span class="mode-name">Bluetooth</span>
|
||
<span class="mode-desc">Device discovery</span>
|
||
</button>
|
||
<button class="mode-card" onclick="selectMode('tscm')">
|
||
<span class="mode-icon">🔍</span>
|
||
<span class="mode-name">TSCM</span>
|
||
<span class="mode-desc">Counter-surveillance</span>
|
||
</button>
|
||
<button class="mode-card" onclick="selectMode('satellite')">
|
||
<span class="mode-icon">🛰️</span>
|
||
<span class="mode-name">Satellite</span>
|
||
<span class="mode-desc">Pass prediction</span>
|
||
</button>
|
||
<button class="mode-card" onclick="selectMode('listening')">
|
||
<span class="mode-icon">📻</span>
|
||
<span class="mode-name">Scanner</span>
|
||
<span class="mode-desc">Frequency scanning</span>
|
||
</button>
|
||
<button class="mode-card" onclick="selectMode('aprs')">
|
||
<span class="mode-icon">📍</span>
|
||
<span class="mode-name">APRS</span>
|
||
<span class="mode-desc">Amateur radio</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div class="welcome-footer">
|
||
<p>Signal Intelligence & Counter Surveillance Platform</p>
|
||
</div>
|
||
</div>
|
||
<div class="welcome-scanline"></div>
|
||
</div>
|
||
|
||
<!-- Disclaimer Modal -->
|
||
<div class="disclaimer-overlay" id="disclaimerModal" style="display: none;">
|
||
<div class="disclaimer-modal">
|
||
<div class="warning-icon">⚠️</div>
|
||
<h2>DISCLAIMER</h2>
|
||
<p>
|
||
<strong>iNTERCEPT</strong> is a signal intelligence tool designed for <strong>educational purposes
|
||
only</strong>.
|
||
</p>
|
||
<p>By using this software, you acknowledge and agree that:</p>
|
||
<ul>
|
||
<li>This tool is intended for use by <strong>cyber security professionals</strong> and researchers only
|
||
</li>
|
||
<li>You will only use this software in a <strong>controlled environment</strong> with proper
|
||
authorization</li>
|
||
<li>Intercepting communications without consent may be <strong>illegal</strong> in your jurisdiction
|
||
</li>
|
||
<li>You are solely responsible for ensuring compliance with all applicable laws and regulations</li>
|
||
<li>The developers assume no liability for misuse of this software</li>
|
||
</ul>
|
||
<p style="color: var(--accent-red); font-weight: bold;">
|
||
Only proceed if you understand and accept these terms.
|
||
</p>
|
||
<div style="display: flex; gap: 15px; justify-content: center; margin-top: 20px;">
|
||
<button class="accept-btn" onclick="acceptDisclaimer()">I UNDERSTAND & ACCEPT</button>
|
||
<button class="accept-btn" onclick="declineDisclaimer()"
|
||
style="background: transparent; border: 1px solid var(--accent-red); color: var(--accent-red);">DECLINE</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TSCM Device Details Modal -->
|
||
<div class="tscm-modal-overlay" id="tscmDeviceModal" style="display: none;"
|
||
onclick="if(event.target === this) closeTscmDeviceModal()">
|
||
<div class="tscm-modal">
|
||
<button class="tscm-modal-close" onclick="closeTscmDeviceModal()">×</button>
|
||
<div id="tscmDeviceModalContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rejection Page -->
|
||
<div class="disclaimer-overlay disclaimer-hidden" id="rejectionPage">
|
||
<div class="disclaimer-modal" style="max-width: 600px;">
|
||
<pre
|
||
style="color: var(--accent-red); font-size: 9px; line-height: 1.1; margin-bottom: 20px; text-align: center;">
|
||
█████╗ ██████╗ ██████╗███████╗███████╗███████╗
|
||
██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
|
||
███████║██║ ██║ █████╗ ███████╗███████╗
|
||
██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
|
||
██║ ██║╚██████╗╚██████╗███████╗███████║███████║
|
||
╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
|
||
██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
|
||
██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
|
||
██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
|
||
██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
|
||
██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
|
||
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
|
||
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
|
||
<p
|
||
style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #888; text-align: left; margin: 0;">
|
||
<span style="color: var(--accent-red);">root@intercepted:</span><span
|
||
style="color: var(--accent-cyan);">~#</span> sudo access --grant-permission<br>
|
||
<span style="color: #666;">[sudo] password for user: ********</span><br>
|
||
<span style="color: var(--accent-red);">Error:</span> User is not in the sudoers file.<br>
|
||
<span style="color: var(--accent-orange);">This incident will be reported.</span>
|
||
</p>
|
||
</div>
|
||
<p style="color: #666; font-size: 11px; text-align: center;">
|
||
"In a world of locked doors, the man with the key is king.<br>
|
||
And you, my friend, just threw away the key."
|
||
</p>
|
||
<button class="accept-btn" onclick="location.reload()"
|
||
style="margin-top: 20px; background: transparent; border: 1px solid var(--accent-cyan); color: var(--accent-cyan);">
|
||
TRY AGAIN
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<header>
|
||
<!-- Hamburger Menu Button (Mobile) -->
|
||
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</button>
|
||
<div class="logo">
|
||
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Signal brackets - left side -->
|
||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
|
||
opacity="0.5" />
|
||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||
opacity="0.7" />
|
||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
|
||
<!-- Signal brackets - right side -->
|
||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
|
||
opacity="0.5" />
|
||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||
opacity="0.7" />
|
||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
|
||
<!-- The 'i' letter -->
|
||
<!-- dot of i -->
|
||
<circle cx="50" cy="22" r="6" fill="#00ff88" />
|
||
<!-- stem of i with styled terminals -->
|
||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
|
||
<!-- top terminal bar -->
|
||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
<!-- bottom terminal bar -->
|
||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
</svg>
|
||
</div>
|
||
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span> <span class="version-badge">v{{ version
|
||
}}</span></h1>
|
||
<p class="subtitle">Signal Intelligence & Counter Surveillance Platform <span class="active-mode-indicator"
|
||
id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span></p>
|
||
|
||
<!-- Header Stats (mode-specific) -->
|
||
<div class="header-stats">
|
||
<!-- Pager Stats -->
|
||
<div class="header-stats-group active" id="headerPagerStats">
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📨</span>
|
||
<div>
|
||
<span class="badge-value" id="headerMsgCount">0</span>
|
||
<span class="badge-label">messages</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📟</span>
|
||
<div>
|
||
<span class="badge-value" id="headerPocsagCount">0</span>
|
||
<span class="badge-label">POCSAG</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📠</span>
|
||
<div>
|
||
<span class="badge-value" id="headerFlexCount">0</span>
|
||
<span class="badge-label">FLEX</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 433MHz Sensor Stats -->
|
||
<div class="header-stats-group" id="headerSensorStats">
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">🌡️</span>
|
||
<div>
|
||
<span class="badge-value" id="headerSensorCount">0</span>
|
||
<span class="badge-label">sensors</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📊</span>
|
||
<div>
|
||
<span class="badge-value" id="headerDeviceTypeCount">0</span>
|
||
<span class="badge-label">types</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Stats -->
|
||
<div class="header-stats-group" id="headerWifiStats">
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📡</span>
|
||
<div>
|
||
<span class="badge-value" id="headerApCount">0</span>
|
||
<span class="badge-label">networks</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">👤</span>
|
||
<div>
|
||
<span class="badge-value" id="headerClientCount">0</span>
|
||
<span class="badge-label">clients</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">🤝</span>
|
||
<div>
|
||
<span class="badge-value highlight" id="headerHandshakeCount">0</span>
|
||
<span class="badge-label">handshakes</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">🚁</span>
|
||
<div>
|
||
<span class="badge-value warning" id="headerDroneCount">0</span>
|
||
<span class="badge-label">drones</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Stats -->
|
||
<div class="header-stats-group" id="headerBtStats">
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">🔵</span>
|
||
<div>
|
||
<span class="badge-value" id="headerBtDeviceCount">0</span>
|
||
<span class="badge-label">devices</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📍</span>
|
||
<div>
|
||
<span class="badge-value" id="headerBtBeaconCount">0</span>
|
||
<span class="badge-label">beacons</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Stats -->
|
||
<div class="header-stats-group" id="headerSatelliteStats">
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">🛰️</span>
|
||
<div>
|
||
<span class="badge-value" id="headerPassCount">0</span>
|
||
<span class="badge-label">passes</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-badge">
|
||
<span class="badge-icon">📡</span>
|
||
<div>
|
||
<span class="badge-value" id="headerBurstCount">0</span>
|
||
<span class="badge-label">bursts</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Mode Navigation Bar -->
|
||
<nav class="mode-nav">
|
||
<div class="mode-nav-dropdown" data-group="sdr">
|
||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('sdr')">
|
||
<span class="nav-icon">📡</span>
|
||
<span class="nav-label">SDR / RF</span>
|
||
<span class="dropdown-arrow">▼</span>
|
||
</button>
|
||
<div class="mode-nav-dropdown-menu">
|
||
<button class="mode-nav-btn active" onclick="switchMode('pager')"><span class="nav-icon">📟</span><span
|
||
class="nav-label">Pager</span></button>
|
||
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon">📡</span><span
|
||
class="nav-label">433MHz</span></button>
|
||
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span
|
||
class="nav-icon">✈️</span><span class="nav-label">Aircraft</span></a>
|
||
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon">📍</span><span
|
||
class="nav-label">APRS</span></button>
|
||
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon">🛰️</span><span
|
||
class="nav-label">Satellite</span></button>
|
||
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon">📻</span><span
|
||
class="nav-label">Listening Post</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="mode-nav-dropdown" data-group="wireless">
|
||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('wireless')">
|
||
<span class="nav-icon">📶</span>
|
||
<span class="nav-label">Wireless</span>
|
||
<span class="dropdown-arrow">▼</span>
|
||
</button>
|
||
<div class="mode-nav-dropdown-menu">
|
||
<button class="mode-nav-btn" onclick="switchMode('wifi')"><span class="nav-icon">📶</span><span
|
||
class="nav-label">WiFi</span></button>
|
||
<button class="mode-nav-btn" onclick="switchMode('bluetooth')"><span class="nav-icon">🔵</span><span
|
||
class="nav-label">Bluetooth</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="mode-nav-dropdown" data-group="security">
|
||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('security')">
|
||
<span class="nav-icon">🔍</span>
|
||
<span class="nav-label">Security</span>
|
||
<span class="dropdown-arrow">▼</span>
|
||
</button>
|
||
<div class="mode-nav-dropdown-menu">
|
||
<button class="mode-nav-btn" onclick="switchMode('tscm')"><span class="nav-icon">🔍</span><span
|
||
class="nav-label">TSCM</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="mode-nav-actions">
|
||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"
|
||
style="display: none;">
|
||
<span class="nav-icon">🖥️</span><span class="nav-label">Full Dashboard</span>
|
||
</a>
|
||
</div>
|
||
<div class="nav-utilities">
|
||
<div class="nav-clock">
|
||
<span class="utc-label">UTC</span>
|
||
<span class="utc-time" id="headerUtcTime">--:--:--</span>
|
||
</div>
|
||
<div class="nav-divider"></div>
|
||
<div class="nav-tools">
|
||
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
||
<span class="icon-moon">🌙</span>
|
||
<span class="icon-sun">☀️</span>
|
||
</button>
|
||
<button class="nav-tool-btn" onclick="showDependencies()" title="Check Tool Dependencies"
|
||
id="depsBtn">🔧</button>
|
||
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
||
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
|
||
<span class="power-icon">⏻</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Mobile Navigation Bar (simplified mode switching) -->
|
||
<nav class="mobile-nav-bar" id="mobileNavBar">
|
||
<button class="mobile-nav-btn active" data-mode="pager" onclick="switchMode('pager')">📟 Pager</button>
|
||
<button class="mobile-nav-btn" data-mode="sensor" onclick="switchMode('sensor')">📡 433MHz</button>
|
||
<a href="/adsb/dashboard" class="mobile-nav-btn" style="text-decoration: none;">✈️ Aircraft</a>
|
||
<button class="mobile-nav-btn" data-mode="aprs" onclick="switchMode('aprs')">📍 APRS</button>
|
||
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')">📶 WiFi</button>
|
||
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')">🔵 BT</button>
|
||
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')">🔍 TSCM</button>
|
||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')">🛰️ Sat</button>
|
||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')">📻 Scanner</button>
|
||
</nav>
|
||
|
||
<!-- Mobile Drawer Overlay -->
|
||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||
|
||
<div class="container">
|
||
<div class="main-content">
|
||
<div class="sidebar mobile-drawer" id="mainSidebar">
|
||
<div class="section" id="rtlDeviceSection">
|
||
<h3>SDR Device</h3>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
|
||
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
|
||
<option value="rtlsdr">RTL-SDR</option>
|
||
<option value="limesdr">LimeSDR</option>
|
||
<option value="hackrf">HackRF</option>
|
||
<option value="airspy">Airspy</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Device</label>
|
||
<select id="deviceSelect">
|
||
{% if devices %}
|
||
{% for device in devices %}
|
||
<option value="{{ device.index }}"
|
||
data-sdr-type="{{ device.sdr_type | default('rtlsdr') }}">{{ device.index }}: {{
|
||
device.name }}</option>
|
||
{% endfor %}
|
||
{% else %}
|
||
<option value="0">No devices found</option>
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
<div id="deviceCapabilities" class="info-text"
|
||
style="font-size: 11px; margin-bottom: 8px; padding: 6px; background: #0a0a1a; border-radius: 4px;">
|
||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||
<span style="color: #888;">Freq:</span><span id="capFreqRange">24-1766 MHz</span>
|
||
<span style="color: #888;">Gain:</span><span id="capGainRange">0-50 dB</span>
|
||
</div>
|
||
</div>
|
||
<!-- Bias-T Power Toggle - Prominent Location -->
|
||
<div
|
||
style="display: flex; align-items: center; gap: 10px; padding: 8px; margin-bottom: 8px; background: linear-gradient(90deg, rgba(255,100,0,0.2), rgba(255,100,0,0.05)); border: 1px solid var(--accent-orange); border-radius: 4px;">
|
||
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()"
|
||
style="width: 18px; height: 18px; accent-color: var(--accent-orange);">
|
||
<div>
|
||
<div style="color: var(--accent-orange); font-weight: bold; font-size: 12px;">⚡ Bias-T Power
|
||
</div>
|
||
<div style="color: #888; font-size: 9px;">Powers external LNA/preamp</div>
|
||
</div>
|
||
</div>
|
||
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
|
||
Refresh Devices
|
||
</button>
|
||
|
||
<!-- Remote SDR (rtl_tcp) -->
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label class="inline-checkbox">
|
||
<input type="checkbox" id="useRemoteSDR" onchange="toggleRemoteSDR()">
|
||
Use Remote SDR (rtl_tcp)
|
||
</label>
|
||
</div>
|
||
<div id="remoteSDRConfig" style="display: none; margin-bottom: 10px;">
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888;">Host</label>
|
||
<input type="text" id="rtlTcpHost" placeholder="192.168.1.100" style="width: 100%;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888;">Port</label>
|
||
<input type="number" id="rtlTcpPort" value="1234" min="1" max="65535" style="width: 100%;">
|
||
</div>
|
||
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
|
||
Connect to rtl_tcp server on remote machine.<br>
|
||
Start server with: <code style="color: #00d4ff;">rtl_tcp -a 0.0.0.0</code>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toolStatusPager" class="info-text tool-status-section"
|
||
style="display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
|
||
<span>rtl_fm:</span><span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK'
|
||
if tools.rtl_fm else 'Missing' }}</span>
|
||
<span>multimon-ng:</span><span
|
||
class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon
|
||
else 'Missing' }}</span>
|
||
</div>
|
||
<div id="toolStatusSensor" class="info-text tool-status-section"
|
||
style="display: none; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
|
||
<span>rtl_433:</span><span class="tool-status {{ 'ok' if tools.rtl_433 else 'missing' }}">{{
|
||
'OK' if tools.rtl_433 else 'Missing' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{% include 'partials/modes/pager.html' %}
|
||
|
||
{% include 'partials/modes/sensor.html' %}
|
||
|
||
{% include 'partials/modes/wifi.html' %}
|
||
|
||
{% include 'partials/modes/bluetooth.html' %}
|
||
|
||
{% include 'partials/modes/aprs.html' %}
|
||
|
||
{% include 'partials/modes/satellite.html' %}
|
||
|
||
{% include 'partials/modes/listening-post.html' %}
|
||
|
||
{% include 'partials/modes/tscm.html' %}
|
||
|
||
<button class="preset-btn" onclick="killAll()"
|
||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||
Kill All Processes
|
||
</button>
|
||
</div>
|
||
|
||
<div class="output-panel">
|
||
<div class="output-header">
|
||
<h3 id="outputTitle">Pager Decoder</h3>
|
||
<div class="header-controls">
|
||
<div class="stats" id="pagerStats">
|
||
<div title="Total Messages">📨 <span id="msgCount">0</span></div>
|
||
<div title="POCSAG Messages">📟 <span id="pocsagCount">0</span></div>
|
||
<div title="FLEX Messages">📠 <span id="flexCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="sensorStats" style="display: none;">
|
||
<div title="Unique Sensors">🌡️ <span id="sensorCount">0</span></div>
|
||
<div title="Device Types">📊 <span id="deviceCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="wifiStats" style="display: none;">
|
||
<div title="Access Points">📡 <span id="apCount">0</span></div>
|
||
<div title="Connected Clients">👤 <span id="clientCount">0</span></div>
|
||
<div title="Captured Handshakes" style="color: var(--accent-green);">🤝 <span
|
||
id="handshakeCount">0</span></div>
|
||
<div style="color: var(--accent-orange); cursor: pointer;" onclick="showDroneDetails()"
|
||
title="Click: Drone details">🚁 <span id="droneCount">0</span></div>
|
||
<div style="color: var(--accent-red); cursor: pointer;" onclick="showRogueApDetails()"
|
||
title="Click: Rogue AP details">⚠️ <span id="rogueApCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="btStats" style="display: none;">
|
||
<div title="Bluetooth Devices">🔵 <span id="btDeviceCount">0</span></div>
|
||
<div title="BLE Beacons">📍 <span id="btBeaconCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="satelliteStats" style="display: none;">
|
||
<div title="Upcoming Passes">🛰️ <span id="passCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Layout Container (visualizations left, device cards right) -->
|
||
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
||
<!-- Left: WiFi Visualizations -->
|
||
<div class="wifi-visuals" id="wifiVisuals">
|
||
<!-- Selected WiFi Device Info - at top for visibility -->
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<h5>📋 Selected Device</h5>
|
||
<div id="wifiSelectedDevice" style="font-size: 11px; min-height: 100px;">
|
||
<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a network
|
||
or client to view details</div>
|
||
</div>
|
||
</div>
|
||
<!-- Row 1: Network Radar + Security Overview -->
|
||
<div class="wifi-visual-panel">
|
||
<h5>Network Radar</h5>
|
||
<div class="radar-container">
|
||
<canvas id="radarCanvas" width="150" height="150"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-visual-panel">
|
||
<h5>Security Overview</h5>
|
||
<div class="security-container">
|
||
<div class="security-donut">
|
||
<canvas id="securityCanvas" width="80" height="80"></canvas>
|
||
</div>
|
||
<div class="security-legend">
|
||
<div class="security-legend-item">
|
||
<div class="security-legend-dot wpa3"></div>WPA3: <span id="wpa3Count">0</span>
|
||
</div>
|
||
<div class="security-legend-item">
|
||
<div class="security-legend-dot wpa2"></div>WPA2: <span id="wpa2Count">0</span>
|
||
</div>
|
||
<div class="security-legend-item">
|
||
<div class="security-legend-dot wep"></div>WEP: <span id="wepCount">0</span>
|
||
</div>
|
||
<div class="security-legend-item">
|
||
<div class="security-legend-dot open"></div>Open: <span id="openCount">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Row 2: Channel Utilization (2.4 GHz + 5 GHz side by side) -->
|
||
<div class="wifi-visual-panel">
|
||
<h5>Channel Utilization (2.4 GHz)</h5>
|
||
<div class="channel-graph" id="channelGraph">
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">1</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">2</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">3</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">4</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">5</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">6</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">7</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">8</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">9</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">10</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">11</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">12</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">13</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-visual-panel">
|
||
<h5>Channel Utilization (5 GHz)</h5>
|
||
<div class="channel-graph" id="channelGraph5g" style="font-size: 7px;">
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">36</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">40</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">44</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">48</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">52</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">56</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">60</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">64</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">100</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">149</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">153</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">157</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">161</span>
|
||
</div>
|
||
<div class="channel-bar-wrapper">
|
||
<div class="channel-bar" style="height: 2px;"></div><span
|
||
class="channel-label">165</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Row 3: Channel Recommendation -->
|
||
<div class="wifi-visual-panel channel-recommendation" id="channelRecommendation">
|
||
<h4>💡 Channel Recommendation</h4>
|
||
<div class="rec-text">
|
||
<strong>2.4 GHz:</strong> Use channel <span class="rec-channel"
|
||
id="rec24Channel">--</span>
|
||
<span id="rec24Reason" style="font-size: 10px; color: var(--text-dim);"></span>
|
||
</div>
|
||
<div class="rec-text" style="margin-top: 5px;">
|
||
<strong>5 GHz:</strong> Use channel <span class="rec-channel" id="rec5Channel">--</span>
|
||
<span id="rec5Reason" style="font-size: 10px; color: var(--text-dim);"></span>
|
||
</div>
|
||
</div>
|
||
<!-- Device Correlation -->
|
||
<div class="wifi-visual-panel" id="correlationPanel">
|
||
<h5>🔗 Device Correlation</h5>
|
||
<div id="correlationList" style="font-size: 11px; max-height: 100px; overflow-y: auto;">
|
||
<div style="color: var(--text-dim);">Analyzing WiFi/BT device patterns...</div>
|
||
</div>
|
||
</div>
|
||
<!-- Hidden SSID Revealer -->
|
||
<div class="wifi-visual-panel" id="hiddenSsidPanel">
|
||
<h5>👁️ Hidden SSIDs Revealed</h5>
|
||
<div id="hiddenSsidList" style="font-size: 11px; max-height: 100px; overflow-y: auto;">
|
||
<div style="color: var(--text-dim);">Monitoring probe requests...</div>
|
||
</div>
|
||
</div>
|
||
<!-- Client Probe Analysis -->
|
||
<div class="wifi-visual-panel" id="probeAnalysisPanel" style="grid-column: span 2;">
|
||
<h5>📡 Client Probe Analysis</h5>
|
||
<div style="display: flex; gap: 10px; margin-bottom: 8px; font-size: 10px;">
|
||
<span>Clients: <strong id="probeClientCount">0</strong></span>
|
||
<span>Unique SSIDs: <strong id="probeSSIDCount">0</strong></span>
|
||
<span>Privacy Leaks: <strong id="probePrivacyCount"
|
||
style="color: var(--accent-orange);">0</strong></span>
|
||
</div>
|
||
<div id="probeAnalysisList" style="font-size: 11px; max-height: 200px; overflow-y: auto;">
|
||
<div style="color: var(--text-dim);">Waiting for client probe requests...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Right: WiFi Device Cards -->
|
||
<div class="wifi-device-list" id="wifiDeviceList">
|
||
<div class="wifi-device-list-header">
|
||
<h5>📡 Discovered Networks</h5>
|
||
<span class="device-count">(<span id="wifiDeviceListCount">0</span>)</span>
|
||
</div>
|
||
<div class="wifi-device-list-content" id="wifiDeviceListContent">
|
||
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
|
||
Start scanning to discover WiFi networks
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Layout Container (visualizations left, device cards right) -->
|
||
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
|
||
<!-- Left: Bluetooth Visualizations -->
|
||
<div class="wifi-visuals" id="btVisuals">
|
||
<!-- Selected Bluetooth Device Info - at top for visibility -->
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<h5>📋 Selected Device</h5>
|
||
<div id="btSelectedDevice" style="font-size: 11px; min-height: 100px;">
|
||
<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a device
|
||
to view details</div>
|
||
</div>
|
||
</div>
|
||
<!-- Row 1: Bluetooth Radar + Device Types -->
|
||
<div class="wifi-visual-panel">
|
||
<h5>Proximity Radar</h5>
|
||
<div class="radar-container">
|
||
<canvas id="btRadarCanvas" width="150" height="150"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-visual-panel">
|
||
<h5>Device Types</h5>
|
||
<div class="bt-type-overview" id="btTypeOverview">
|
||
<div class="bt-type-item"><span class="bt-type-icon">📱</span> Phones: <strong
|
||
id="btPhoneCount">0</strong></div>
|
||
<div class="bt-type-item"><span class="bt-type-icon">💻</span> Computers: <strong
|
||
id="btComputerCount">0</strong></div>
|
||
<div class="bt-type-item"><span class="bt-type-icon">🎧</span> Audio: <strong
|
||
id="btAudioCount">0</strong></div>
|
||
<div class="bt-type-item"><span class="bt-type-icon">⌚</span> Wearables: <strong
|
||
id="btWearableCount">0</strong></div>
|
||
<div class="bt-type-item"><span class="bt-type-icon">🔵</span> Other: <strong
|
||
id="btOtherCount">0</strong></div>
|
||
</div>
|
||
</div>
|
||
<!-- Row 2: Tracker Detection + Signal Analysis -->
|
||
<div class="wifi-visual-panel">
|
||
<h5>📍 Tracker Detection</h5>
|
||
<div id="btTrackerList" style="max-height: 120px; overflow-y: auto; font-size: 11px;">
|
||
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for
|
||
AirTags, Tiles...</div>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-visual-panel">
|
||
<h5>📶 Signal Distribution</h5>
|
||
<div class="bt-signal-dist" id="btSignalDist">
|
||
<div class="signal-range"><span>Strong (-50+)</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
|
||
</div><span id="btSignalStrongCount">0</span>
|
||
</div>
|
||
<div class="signal-range"><span>Medium (-70)</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
|
||
</div><span id="btSignalMediumCount">0</span>
|
||
</div>
|
||
<div class="signal-range"><span>Weak (-90)</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
|
||
</div><span id="btSignalWeakCount">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Row 3: FindMy Detection -->
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<h5>🍎 Apple FindMy Network</h5>
|
||
<div id="btFindMyList" style="max-height: 100px; overflow-y: auto; font-size: 11px;">
|
||
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Scanning for
|
||
FindMy-compatible devices...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Right: Bluetooth Device Cards -->
|
||
<div class="wifi-device-list bt-device-list" id="btDeviceListPanel">
|
||
<div class="wifi-device-list-header">
|
||
<h5>🔵 Bluetooth Devices</h5>
|
||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||
</div>
|
||
<div class="wifi-device-list-content" id="btDeviceListContent">
|
||
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
|
||
Start scanning to discover Bluetooth devices
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- APRS Visualizations -->
|
||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px;">
|
||
<!-- APRS Function Bar -->
|
||
<div class="aprs-strip">
|
||
<div class="aprs-strip-inner">
|
||
<!-- Stats -->
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripFreq">--</span>
|
||
<span class="strip-label">MHz</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripStations">0</span>
|
||
<span class="strip-label">STATIONS</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripPackets">0</span>
|
||
<span class="strip-label">PACKETS</span>
|
||
</div>
|
||
<div class="strip-stat signal-stat" id="aprsStripSignalStat">
|
||
<span class="strip-value" id="aprsStripSignal">--</span>
|
||
<span class="strip-label">SIGNAL</span>
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<!-- Controls -->
|
||
<div class="strip-control">
|
||
<select id="aprsStripRegion" class="strip-select">
|
||
<option value="north_america">N. America (144.390)</option>
|
||
<option value="europe">Europe (144.800)</option>
|
||
<option value="uk">UK (144.800)</option>
|
||
<option value="australia">Australia (145.175)</option>
|
||
<option value="japan">Japan (144.640)</option>
|
||
</select>
|
||
</div>
|
||
<div class="strip-control">
|
||
<span class="strip-input-label">GAIN</span>
|
||
<input type="number" id="aprsStripGain" class="strip-input" value="40" min="0" max="50">
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<!-- Tool Status Indicators -->
|
||
<div class="strip-tools">
|
||
<span class="strip-tool" id="aprsStripDirewolf" title="direwolf">DW</span>
|
||
<span class="strip-tool" id="aprsStripMultimon" title="multimon-ng">MM</span>
|
||
</div>
|
||
<span id="aprsGpsIndicator" class="gps-indicator" style="display: none;"
|
||
title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||
<div class="strip-divider"></div>
|
||
<!-- Actions -->
|
||
<button type="button" class="strip-btn primary" id="aprsStripStartBtn"
|
||
onclick="startAprs()">
|
||
▶ START
|
||
</button>
|
||
<button type="button" class="strip-btn stop" id="aprsStripStopBtn" onclick="stopAprs()"
|
||
style="display: none;">
|
||
◼ STOP
|
||
</button>
|
||
<!-- Status -->
|
||
<div class="strip-status">
|
||
<div class="status-dot inactive" id="aprsStripDot"></div>
|
||
<span id="aprsStripStatus">STANDBY</span>
|
||
</div>
|
||
<div class="strip-time" id="aprsStripTime">--:--:-- UTC</div>
|
||
</div>
|
||
</div>
|
||
<!-- Top row: Map and Station List side by side -->
|
||
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
||
<!-- Map Panel (larger) -->
|
||
<div class="wifi-visual-panel"
|
||
style="flex: 2; display: flex; flex-direction: column; min-width: 0;">
|
||
<h5
|
||
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
|
||
APRS STATION MAP</h5>
|
||
<div class="aircraft-map-container" style="flex: 1; display: flex; flex-direction: column;">
|
||
<div class="map-header">
|
||
<span id="aprsMapTime">--:--:--</span>
|
||
<span id="aprsMapStatus">STANDBY</span>
|
||
</div>
|
||
<div id="aprsMap" style="flex: 1; min-height: 350px;"></div>
|
||
<div class="map-footer">
|
||
<span>STATIONS: <span id="aprsStationCount">0</span></span>
|
||
<span>PACKETS: <span id="aprsPacketCount">0</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Station List Panel -->
|
||
<div class="wifi-visual-panel"
|
||
style="flex: 1; min-width: 280px; max-width: 350px; display: flex; flex-direction: column;">
|
||
<h5
|
||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px;">
|
||
STATION LIST</h5>
|
||
<div id="aprsStationList" style="flex: 1; overflow-y: auto; font-size: 11px;">
|
||
<div style="padding: 20px; text-align: center; color: var(--text-muted);">
|
||
No stations received yet
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom row: Packet Log -->
|
||
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px;">
|
||
<h5
|
||
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
||
PACKET LOG</h5>
|
||
<div id="aprsPacketLog"
|
||
style="flex: 1; overflow-y: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-muted);">Waiting for packets...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
||
|
||
<!-- TOP: FREQUENCY DISPLAY PANEL -->
|
||
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
|
||
<div style="display: flex; gap: 15px; align-items: stretch;">
|
||
<!-- Main Frequency Display -->
|
||
<div
|
||
style="flex: 1; text-align: center; padding: 15px 20px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
|
||
<div class="freq-status" id="mainScannerModeLabel"
|
||
style="font-size: 10px; color: var(--text-muted); margin-bottom: 4px; letter-spacing: 3px;">
|
||
STOPPED</div>
|
||
<div style="display: flex; justify-content: center; align-items: baseline; gap: 8px;">
|
||
<div class="freq-digits" id="mainScannerFreq"
|
||
style="font-size: 52px; font-weight: bold; color: var(--accent-cyan); text-shadow: 0 0 30px var(--accent-cyan); font-family: 'JetBrains Mono', monospace; letter-spacing: 3px;">
|
||
118.000</div>
|
||
<span class="freq-unit"
|
||
style="font-size: 20px; color: var(--text-secondary); font-weight: 500;">MHz</span>
|
||
</div>
|
||
<div
|
||
style="display: flex; justify-content: center; align-items: center; margin-top: 6px;">
|
||
<span class="freq-mode-badge" id="mainScannerMod"
|
||
style="background: var(--accent-cyan); color: #000; padding: 3px 12px; border-radius: 4px; font-size: 12px; font-weight: bold;">AM</span>
|
||
</div>
|
||
<!-- Progress bar -->
|
||
<div id="mainScannerProgress" style="display: none; margin-top: 12px;">
|
||
<div
|
||
style="display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted); margin-bottom: 3px;">
|
||
<span id="mainRangeStart">--</span>
|
||
<span id="mainRangeEnd">--</span>
|
||
</div>
|
||
<div
|
||
style="height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
|
||
<div class="scan-bar" id="mainProgressBar"
|
||
style="height: 100%; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); width: 0%; transition: width 0.1s;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Synthesizer + Audio Output Panel -->
|
||
<div style="width: 320px; display: flex; flex-direction: column; gap: 8px;">
|
||
<!-- Synthesizer Display -->
|
||
<div
|
||
style="flex: 1; padding: 10px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
|
||
<div
|
||
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;">
|
||
Synthesizer</div>
|
||
<canvas id="synthesizerCanvas"
|
||
style="width: 100%; height: 60px; background: rgba(0,0,0,0.4); border-radius: 4px;"></canvas>
|
||
</div>
|
||
<!-- Audio Output -->
|
||
<div
|
||
style="padding: 10px; background: rgba(0,0,0,0.4); border-radius: 6px; border: 1px solid var(--border-color);">
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
|
||
<div
|
||
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">
|
||
Audio Output</div>
|
||
</div>
|
||
<audio id="scannerAudioPlayer" style="width: 100%; height: 28px;" controls></audio>
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||
<span style="font-size: 7px; color: var(--text-muted);">LEVEL</span>
|
||
<div
|
||
style="flex: 1; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden;">
|
||
<div id="audioSignalBar"
|
||
style="height: 100%; background: linear-gradient(90deg, var(--accent-green), var(--accent-cyan), var(--accent-orange), var(--accent-red)); width: 0%; transition: width 0.1s;">
|
||
</div>
|
||
</div>
|
||
<span id="audioSignalDb"
|
||
style="font-size: 8px; color: var(--text-muted); min-width: 40px; text-align: right;">--
|
||
dB</span>
|
||
</div>
|
||
<!-- Signal Alert inline -->
|
||
<div id="mainSignalAlert"
|
||
style="display: none; background: rgba(0, 255, 100, 0.2); border: 1px solid var(--accent-green); border-radius: 4px; padding: 5px; text-align: center; margin-top: 8px;">
|
||
<span style="font-size: 9px; color: var(--accent-green); font-weight: bold;">●
|
||
SIGNAL</span>
|
||
<button class="tune-btn" onclick="skipSignal()"
|
||
style="margin-left: 8px; padding: 2px 8px; font-size: 8px;">Skip</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CONTROL PANEL: Tuning Section | Mode/Band | Action Buttons -->
|
||
<div class="radio-module-box" style="grid-column: span 4; padding: 12px;">
|
||
<div style="display: flex; gap: 15px; align-items: stretch;">
|
||
|
||
<!-- LEFT: Tuning Section -->
|
||
<div
|
||
style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 15px; background: rgba(0,0,0,0.3); border-radius: 8px; border: 1px solid var(--border-color);">
|
||
|
||
<!-- Fine Tune Buttons (Left of dial) -->
|
||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||
<button class="tune-btn" onclick="tuneFreq(-1)"
|
||
style="padding: 8px 12px; font-size: 11px;">-1</button>
|
||
<button class="tune-btn" onclick="tuneFreq(-0.1)"
|
||
style="padding: 8px 12px; font-size: 11px;">-.1</button>
|
||
</div>
|
||
|
||
<!-- Main Tuning Dial -->
|
||
<div style="display: flex; flex-direction: column; align-items: center;">
|
||
<div class="tuning-dial" id="mainTuningDial" data-value="118" data-min="24"
|
||
data-max="1800" data-step="0.1" style="width: 100px; height: 100px;"></div>
|
||
<div
|
||
style="font-size: 9px; color: var(--text-muted); margin-top: 6px; text-transform: uppercase; letter-spacing: 1px;">
|
||
Tune</div>
|
||
</div>
|
||
|
||
<!-- Fine Tune Buttons (Right of dial) -->
|
||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||
<button class="tune-btn" onclick="tuneFreq(0.1)"
|
||
style="padding: 8px 12px; font-size: 11px;">+.1</button>
|
||
<button class="tune-btn" onclick="tuneFreq(1)"
|
||
style="padding: 8px 12px; font-size: 11px;">+1</button>
|
||
</div>
|
||
|
||
<!-- Divider -->
|
||
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
|
||
|
||
<!-- Settings: Step & Dwell -->
|
||
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 90px;">
|
||
<div
|
||
style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
|
||
<span style="color: var(--text-muted);">Step</span>
|
||
<select id="radioScanStep"
|
||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
|
||
<option value="8.33">8.33k</option>
|
||
<option value="12.5">12.5k</option>
|
||
<option value="25" selected>25k</option>
|
||
<option value="50">50k</option>
|
||
<option value="100">100k</option>
|
||
</select>
|
||
</div>
|
||
<div
|
||
style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
|
||
<span style="color: var(--text-muted);">Dwell</span>
|
||
<select id="radioScanDwell"
|
||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
|
||
<option value="2">2s</option>
|
||
<option value="5">5s</option>
|
||
<option value="10" selected>10s</option>
|
||
<option value="30">30s</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Divider -->
|
||
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
|
||
|
||
<!-- SQL, Gain, Vol Knobs -->
|
||
<div style="display: flex; gap: 15px;">
|
||
<div class="knob-container">
|
||
<div class="radio-knob" id="radioSquelchKnob" data-value="30" data-min="0"
|
||
data-max="100" data-step="1"></div>
|
||
<div class="knob-label">SQL</div>
|
||
<div class="knob-value" id="radioSquelchValue">30</div>
|
||
</div>
|
||
<div class="knob-container">
|
||
<div class="radio-knob" id="radioGainKnob" data-value="40" data-min="0"
|
||
data-max="50" data-step="1"></div>
|
||
<div class="knob-label">GAIN</div>
|
||
<div class="knob-value" id="radioGainValue">40</div>
|
||
</div>
|
||
<div class="knob-container">
|
||
<div class="radio-knob" id="radioVolumeKnob" data-value="80" data-min="0"
|
||
data-max="100" data-step="1"></div>
|
||
<div class="knob-label">VOL</div>
|
||
<div class="knob-value" id="radioVolumeValue">80</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CENTER: Mode & Band (Stacked) -->
|
||
<div
|
||
style="width: 130px; display: flex; flex-direction: column; gap: 10px; justify-content: center;">
|
||
<div>
|
||
<div
|
||
style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">
|
||
Modulation</div>
|
||
<div class="radio-button-bank compact" id="modBtnBank"
|
||
style="flex-wrap: wrap; gap: 2px;">
|
||
<button class="radio-btn active" data-mod="am" onclick="setModulation('am')"
|
||
style="padding: 6px 10px; font-size: 10px;">AM</button>
|
||
<button class="radio-btn" data-mod="fm" onclick="setModulation('fm')"
|
||
style="padding: 6px 10px; font-size: 10px;">NFM</button>
|
||
<button class="radio-btn" data-mod="wfm" onclick="setModulation('wfm')"
|
||
style="padding: 6px 10px; font-size: 10px;">WFM</button>
|
||
<button class="radio-btn" data-mod="usb" onclick="setModulation('usb')"
|
||
style="padding: 6px 10px; font-size: 10px;">USB</button>
|
||
<button class="radio-btn" data-mod="lsb" onclick="setModulation('lsb')"
|
||
style="padding: 6px 10px; font-size: 10px;">LSB</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div
|
||
style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">
|
||
Band</div>
|
||
<div class="radio-button-bank compact" id="bandBtnBank"
|
||
style="flex-wrap: wrap; gap: 2px;">
|
||
<button class="radio-btn" data-band="fm" onclick="setBand('fm')"
|
||
style="padding: 6px 10px; font-size: 10px;">FM</button>
|
||
<button class="radio-btn active" data-band="air" onclick="setBand('air')"
|
||
style="padding: 6px 10px; font-size: 10px;">AIR</button>
|
||
<button class="radio-btn" data-band="marine" onclick="setBand('marine')"
|
||
style="padding: 6px 10px; font-size: 10px;">MAR</button>
|
||
<button class="radio-btn" data-band="amateur2m" onclick="setBand('amateur2m')"
|
||
style="padding: 6px 10px; font-size: 10px;">2M</button>
|
||
<button class="radio-btn" data-band="amateur70cm"
|
||
onclick="setBand('amateur70cm')"
|
||
style="padding: 6px 10px; font-size: 10px;">70CM</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Scan Range + Action Buttons -->
|
||
<div style="width: 175px; display: flex; flex-direction: column; gap: 8px;">
|
||
<!-- Frequency Range - Prominent -->
|
||
<div
|
||
style="background: rgba(0,0,0,0.4); border: 1px solid var(--border-color); border-radius: 6px; padding: 10px;">
|
||
<div
|
||
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; text-align: center;">
|
||
Scan Range (MHz)</div>
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<div style="flex: 1;">
|
||
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">
|
||
START</div>
|
||
<input type="number" id="radioScanStart" value="118" step="0.1"
|
||
class="radio-input"
|
||
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
|
||
</div>
|
||
<span
|
||
style="color: var(--text-muted); font-size: 16px; padding-top: 12px;">→</span>
|
||
<div style="flex: 1;">
|
||
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">
|
||
END</div>
|
||
<input type="number" id="radioScanEnd" value="137" step="0.1"
|
||
class="radio-input"
|
||
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Action Buttons -->
|
||
<button class="radio-action-btn scan" id="radioScanBtn" onclick="toggleScanner()"
|
||
style="padding: 12px; font-size: 13px; width: 100%; font-weight: bold;">📡
|
||
SCAN</button>
|
||
<button class="radio-action-btn" id="radioListenBtn" onclick="toggleDirectListen()"
|
||
style="padding: 10px; font-size: 12px; width: 100%; background: var(--accent-green); border: none; color: #fff;">🎧
|
||
LISTEN</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- QUICK TUNE BAR -->
|
||
<div class="radio-module-box" style="grid-column: span 4; padding: 8px 12px;">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<span
|
||
style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Quick
|
||
Tune:</span>
|
||
<button class="preset-freq-btn" onclick="quickTune(121.5, 'am')">121.5 GUARD</button>
|
||
<button class="preset-freq-btn" onclick="quickTune(156.8, 'fm')">156.8 CH16</button>
|
||
<button class="preset-freq-btn" onclick="quickTune(145.5, 'fm')">145.5 2M</button>
|
||
<button class="preset-freq-btn" onclick="quickTune(98.1, 'wfm')">98.1 FM</button>
|
||
<button class="preset-freq-btn" onclick="quickTune(462.5625, 'fm')">462.56 FRS</button>
|
||
<button class="preset-freq-btn" onclick="quickTune(446.0, 'fm')">446.0 PMR</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SIGNAL HITS -->
|
||
<div class="radio-module-box" style="grid-column: span 2; padding: 10px;">
|
||
<div class="module-header"
|
||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
|
||
<span>SIGNAL HITS</span>
|
||
<span
|
||
style="font-size: 10px; color: var(--accent-cyan); background: rgba(0,212,255,0.1); padding: 2px 8px; border-radius: 3px;"
|
||
id="scannerHitCount">0 signals</span>
|
||
</div>
|
||
<div id="scannerHitsList" style="overflow-y: auto; max-height: 100px;">
|
||
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||
<thead>
|
||
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
|
||
<th style="text-align: left; padding: 4px;">Time</th>
|
||
<th style="text-align: left; padding: 4px;">Frequency</th>
|
||
<th style="text-align: left; padding: 4px;">Mod</th>
|
||
<th style="text-align: center; padding: 4px; width: 60px;">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="scannerHitsBody">
|
||
<tr style="color: var(--text-muted);">
|
||
<td colspan="4" style="padding: 15px; text-align: center; font-size: 10px;">No
|
||
signals detected</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STATS PANEL -->
|
||
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
|
||
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
|
||
<span>STATS</span>
|
||
</div>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||
<span style="font-size: 9px; color: var(--text-muted);">SIGNALS</span>
|
||
<span
|
||
style="color: var(--accent-green); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
|
||
id="mainSignalCount">0</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||
<span style="font-size: 9px; color: var(--text-muted);">SCANNED</span>
|
||
<span
|
||
style="color: var(--accent-cyan); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
|
||
id="mainFreqsScanned">0</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||
<span style="font-size: 9px; color: var(--text-muted);">CYCLES</span>
|
||
<span
|
||
style="color: var(--accent-orange); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
|
||
id="mainScanCycles">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ACTIVITY LOG -->
|
||
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
|
||
<div class="module-header"
|
||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
|
||
<span>LOG</span>
|
||
<div style="display: flex; gap: 4px;">
|
||
<button class="tune-btn" onclick="exportScannerLog()"
|
||
style="padding: 2px 6px; font-size: 8px;">Export</button>
|
||
<button class="tune-btn" onclick="clearScannerLog()"
|
||
style="padding: 2px 6px; font-size: 8px;">Clear</button>
|
||
</div>
|
||
</div>
|
||
<div class="log-content" id="scannerActivityLog"
|
||
style="max-height: 100px; overflow-y: auto; font-size: 9px; background: rgba(0,0,0,0.2); border-radius: 3px; padding: 6px;">
|
||
<div class="scanner-log-entry" style="color: var(--text-muted);">Ready</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Dashboard (Embedded) -->
|
||
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
||
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
|
||
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
|
||
allowfullscreen>
|
||
</iframe>
|
||
</div>
|
||
|
||
<!-- TSCM Dashboard -->
|
||
<div id="tscmVisuals" class="tscm-dashboard" style="display: none; padding: 16px;">
|
||
<!-- Legal Disclaimer Banner -->
|
||
<div class="tscm-legal-banner"
|
||
style="margin-bottom: 12px; padding: 8px 12px; background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; font-size: 10px; color: #ffffff;">
|
||
<strong>TSCM Screening Tool:</strong> This system identifies wireless and RF anomalies.
|
||
Findings are indicators, NOT confirmed surveillance devices.
|
||
No content is intercepted or decoded. Professional verification required.
|
||
</div>
|
||
|
||
<!-- Active Meeting Banner (hidden by default) -->
|
||
<div id="tscmMeetingBanner" class="tscm-meeting-banner" style="display: none;">
|
||
<div class="meeting-indicator">
|
||
<span class="meeting-pulse"></span>
|
||
<span class="meeting-text">MEETING WINDOW ACTIVE</span>
|
||
</div>
|
||
<div class="meeting-info">
|
||
<span id="tscmMeetingBannerName"></span>
|
||
<span id="tscmMeetingBannerTime"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capabilities Summary Bar -->
|
||
<div id="tscmCapabilitiesBar" class="tscm-capabilities-bar" style="display: none;">
|
||
<div class="cap-item" id="capWifi" title="WiFi Capability">
|
||
<span class="cap-icon">📶</span>
|
||
<span class="cap-status" id="capWifiStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capBt" title="Bluetooth Capability">
|
||
<span class="cap-icon">🔵</span>
|
||
<span class="cap-status" id="capBtStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capRf" title="RF/SDR Capability">
|
||
<span class="cap-icon">📡</span>
|
||
<span class="cap-status" id="capRfStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capRoot" title="Privilege Level">
|
||
<span class="cap-icon">🔒</span>
|
||
<span class="cap-status" id="capRootStatus">--</span>
|
||
</div>
|
||
<div class="cap-limitations" id="capLimitations" onclick="tscmShowCapabilities()"
|
||
style="cursor: pointer;">
|
||
<span class="cap-warn">⚠️</span>
|
||
<span id="capLimitationCount">0</span> limitations
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Baseline Health Indicator -->
|
||
<div id="tscmBaselineHealth" class="tscm-baseline-health" style="display: none;">
|
||
<span class="health-label">Baseline:</span>
|
||
<span class="health-name" id="baselineHealthName">--</span>
|
||
<span class="health-badge" id="baselineHealthBadge">--</span>
|
||
<span class="health-age" id="baselineHealthAge"></span>
|
||
</div>
|
||
|
||
<!-- Risk Summary Banner (new scoring model) - clickable cards -->
|
||
<div class="tscm-threat-banner">
|
||
<div class="threat-card critical clickable" id="tscmHighInterestCard"
|
||
onclick="showDevicesByCategory('high_interest')"
|
||
title="Click to view high interest devices">
|
||
<span class="count" id="tscmHighInterestCount">0</span>
|
||
<span class="label">High Interest</span>
|
||
</div>
|
||
<div class="threat-card high clickable" id="tscmNeedsReviewCard"
|
||
onclick="showDevicesByCategory('review')" title="Click to view devices needing review">
|
||
<span class="count" id="tscmNeedsReviewCount">0</span>
|
||
<span class="label">Needs Review</span>
|
||
</div>
|
||
<div class="threat-card low clickable" id="tscmInformationalCard"
|
||
onclick="showDevicesByCategory('informational')"
|
||
title="Click to view informational devices">
|
||
<span class="count" id="tscmInformationalCount">0</span>
|
||
<span class="label">Informational</span>
|
||
</div>
|
||
<div class="threat-card medium clickable" id="tscmCorrelationsCard"
|
||
onclick="showDevicesByCategory('correlations')" title="Click to view correlations">
|
||
<span class="count" id="tscmCorrelationsCount">0</span>
|
||
<span class="label">Correlations</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sweep Summary (shown after sweep completes) -->
|
||
<div id="tscmSweepSummary" style="display: none; margin-bottom: 16px;"></div>
|
||
|
||
<!-- Cross-Protocol Correlations (shown when correlations found) -->
|
||
<div id="tscmCorrelationsContainer" style="display: none;"></div>
|
||
|
||
<!-- Main Content Grid -->
|
||
<div class="tscm-main-grid">
|
||
<!-- WiFi Panel -->
|
||
<div class="tscm-panel">
|
||
<div class="tscm-panel-header">
|
||
WiFi Networks
|
||
<span class="badge" id="tscmWifiCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmWifiList">
|
||
<div class="tscm-empty">Start a sweep to scan for WiFi networks</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Panel -->
|
||
<div class="tscm-panel">
|
||
<div class="tscm-panel-header">
|
||
Bluetooth Devices
|
||
<span class="badge" id="tscmBtCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmBtList">
|
||
<div class="tscm-empty">Start a sweep to scan for Bluetooth devices</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RF Signals Panel -->
|
||
<div class="tscm-panel">
|
||
<div class="tscm-panel-header">
|
||
RF Signals
|
||
<span class="badge" id="tscmRfCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmRfList">
|
||
<div class="tscm-empty">Enable RF scanning with an SDR device</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Threats Panel -->
|
||
<div class="tscm-panel" style="grid-column: span 2;">
|
||
<div class="tscm-panel-header">
|
||
Detected Threats
|
||
<span class="badge" id="tscmThreatCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmThreatList">
|
||
<div class="tscm-empty">No threats detected</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||
<div class="recon-panel collapsed" id="reconPanel">
|
||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||
<h4><span id="reconCollapseIcon">▶</span> Device Intelligence</h4>
|
||
<div class="recon-stats">
|
||
<div>TRACKED: <span id="trackedCount">0</span></div>
|
||
<div>NEW: <span id="newDeviceCount">0</span></div>
|
||
<div>ANOMALIES: <span id="anomalyCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="recon-content" id="reconContent">
|
||
<div style="color: #444; text-align: center; padding: 20px; font-size: 11px;">
|
||
Device intelligence data will appear here as signals are intercepted.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="output-content" id="output">
|
||
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
|
||
Configure settings and click "Start Decoding" to begin.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pager Waterfall (pager mode only, at bottom, collapsible) -->
|
||
<div class="sensor-waterfall-panel collapsed" id="pagerWaterfallPanel" style="display: none;">
|
||
<div class="sensor-waterfall-header" onclick="togglePagerWaterfall()" style="cursor: pointer;">
|
||
<span id="pagerWaterfallIcon">▶</span> Signal Waterfall
|
||
</div>
|
||
<div class="sensor-waterfall-content" id="pagerWaterfallContent">
|
||
<canvas id="waterfallCanvas" width="800" height="30"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sensor Waterfall (433MHz mode only, at bottom, collapsible) -->
|
||
<div class="sensor-waterfall-panel collapsed" id="sensorWaterfallPanel" style="display: none;">
|
||
<div class="sensor-waterfall-header" onclick="toggleSensorWaterfall()" style="cursor: pointer;">
|
||
<span id="sensorWaterfallIcon">▶</span> Signal Waterfall
|
||
</div>
|
||
<div class="sensor-waterfall-content" id="sensorWaterfallContent">
|
||
<canvas id="sensorWaterfallCanvas" width="800" height="30"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar">
|
||
<div class="status-indicator">
|
||
<div class="status-dot" id="statusDot"></div>
|
||
<span id="statusText">Idle</span>
|
||
</div>
|
||
<div class="status-controls">
|
||
<div class="control-group">
|
||
<span class="control-group-label">Mode</span>
|
||
<button id="reconBtn" class="recon-toggle" onclick="toggleRecon()">RECON</button>
|
||
<button id="muteBtn" class="control-btn" onclick="toggleMute()">🔊</button>
|
||
<button id="autoScrollBtn" class="control-btn active" onclick="toggleAutoScroll()">⬇
|
||
AUTO</button>
|
||
</div>
|
||
<div class="control-group">
|
||
<span class="control-group-label">Export</span>
|
||
<button class="control-btn" onclick="exportCSV()">CSV</button>
|
||
<button class="control-btn" onclick="exportJSON()">JSON</button>
|
||
<button class="control-btn" onclick="exportDeviceDB()"
|
||
title="Export Device Intelligence">INTEL</button>
|
||
</div>
|
||
<button class="clear-btn" onclick="clearMessages()">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intercept JS Modules -->
|
||
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||
|
||
<script>
|
||
// Selected mode from welcome screen
|
||
let selectedStartMode = 'pager';
|
||
|
||
// Mode selection from welcome page
|
||
function selectMode(mode) {
|
||
selectedStartMode = mode;
|
||
const welcome = document.getElementById('welcomePage');
|
||
welcome.classList.add('fade-out');
|
||
|
||
// After fade out, hide welcome and show disclaimer if needed
|
||
setTimeout(() => {
|
||
welcome.style.display = 'none';
|
||
checkDisclaimer();
|
||
}, 400);
|
||
}
|
||
|
||
// Disclaimer handling
|
||
function checkDisclaimer() {
|
||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||
if (accepted === 'true') {
|
||
document.getElementById('disclaimerModal').style.display = 'none';
|
||
// Switch to the selected mode
|
||
switchMode(selectedStartMode);
|
||
} else {
|
||
document.getElementById('disclaimerModal').style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function acceptDisclaimer() {
|
||
localStorage.setItem('disclaimerAccepted', 'true');
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
// Switch to the selected mode after accepting
|
||
setTimeout(() => {
|
||
switchMode(selectedStartMode);
|
||
}, 300);
|
||
}
|
||
|
||
function declineDisclaimer() {
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||
}
|
||
|
||
// Don't auto-check disclaimer - wait for welcome page mode selection
|
||
|
||
let eventSource = null;
|
||
let isRunning = false;
|
||
let isSensorRunning = false;
|
||
let isWifiRunning = false;
|
||
let isBtRunning = false;
|
||
let currentMode = 'pager';
|
||
let msgCount = 0;
|
||
let pocsagCount = 0;
|
||
let flexCount = 0;
|
||
let sensorCount = 0;
|
||
let filteredCount = 0; // Count of filtered messages
|
||
let deviceList = {{ devices | tojson | safe }};
|
||
|
||
// Pager message filter settings
|
||
let pagerFilters = {
|
||
hideToneOnly: true,
|
||
keywords: []
|
||
};
|
||
|
||
// UTC Clock Update
|
||
function updateHeaderClock() {
|
||
const now = new Date();
|
||
const utc = now.toISOString().substring(11, 19);
|
||
document.getElementById('headerUtcTime').textContent = utc;
|
||
}
|
||
// Update clock every second
|
||
setInterval(updateHeaderClock, 1000);
|
||
updateHeaderClock(); // Initial call
|
||
|
||
// Pager message filter functions
|
||
function loadPagerFilters() {
|
||
const saved = localStorage.getItem('pagerFilters');
|
||
if (saved) {
|
||
try {
|
||
pagerFilters = JSON.parse(saved);
|
||
} catch (e) {
|
||
console.warn('Failed to load pager filters:', e);
|
||
}
|
||
}
|
||
// Update UI
|
||
document.getElementById('filterToneOnly').checked = pagerFilters.hideToneOnly;
|
||
document.getElementById('filterKeywords').value = pagerFilters.keywords.join(', ');
|
||
}
|
||
|
||
function savePagerFilters() {
|
||
pagerFilters.hideToneOnly = document.getElementById('filterToneOnly').checked;
|
||
const keywordsInput = document.getElementById('filterKeywords').value;
|
||
pagerFilters.keywords = keywordsInput
|
||
.split(',')
|
||
.map(k => k.trim().toLowerCase())
|
||
.filter(k => k.length > 0);
|
||
localStorage.setItem('pagerFilters', JSON.stringify(pagerFilters));
|
||
}
|
||
|
||
function shouldFilterMessage(msg) {
|
||
// Check for Tone Only filter
|
||
if (pagerFilters.hideToneOnly) {
|
||
if (msg.message === '[Tone Only]' || msg.msg_type === 'Tone') {
|
||
return true;
|
||
}
|
||
}
|
||
// Check keyword filters
|
||
if (pagerFilters.keywords.length > 0) {
|
||
const msgLower = (msg.message || '').toLowerCase();
|
||
for (const keyword of pagerFilters.keywords) {
|
||
if (msgLower.includes(keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Sync header stats with output panel stats
|
||
function syncHeaderStats() {
|
||
// Pager stats
|
||
document.getElementById('headerMsgCount').textContent = msgCount;
|
||
document.getElementById('headerPocsagCount').textContent = pocsagCount;
|
||
document.getElementById('headerFlexCount').textContent = flexCount;
|
||
|
||
// Sensor stats
|
||
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||
|
||
// WiFi stats
|
||
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
|
||
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
|
||
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
|
||
|
||
// Bluetooth stats
|
||
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||
|
||
// Satellite stats
|
||
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
|
||
}
|
||
// Sync stats periodically
|
||
setInterval(syncHeaderStats, 500);
|
||
|
||
// Observer location for distance calculations (load from localStorage or default to London)
|
||
let observerLocation = (function () {
|
||
const saved = localStorage.getItem('observerLocation');
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
if (parsed.lat && parsed.lon) return parsed;
|
||
} catch (e) { }
|
||
}
|
||
return { lat: 51.5074, lon: -0.1278 };
|
||
})();
|
||
|
||
// GPS Dongle state
|
||
let gpsConnected = false;
|
||
let gpsEventSource = null;
|
||
let gpsLastPosition = null;
|
||
|
||
// Satellite state
|
||
let satellitePasses = [];
|
||
let selectedPass = null;
|
||
let selectedPassIndex = 0;
|
||
let countdownInterval = null;
|
||
|
||
// Start satellite countdown timer
|
||
function startCountdownTimer() {
|
||
if (countdownInterval) clearInterval(countdownInterval);
|
||
countdownInterval = setInterval(updateSatelliteCountdown, 1000);
|
||
}
|
||
|
||
// Update satellite countdown display
|
||
function updateSatelliteCountdown() {
|
||
// Update both main and popout countdowns
|
||
updateCountdownDisplay('');
|
||
updateCountdownDisplay('Popout');
|
||
}
|
||
|
||
// Helper to update countdown elements by suffix
|
||
function updateCountdownDisplay(suffix) {
|
||
const container = document.getElementById('satelliteCountdown' + suffix);
|
||
if (!container) return;
|
||
|
||
// Use the globally selected pass
|
||
if (!selectedPass || satellitePasses.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const now = new Date();
|
||
const startTime = parsePassTime(selectedPass.startTime);
|
||
const endTime = new Date(startTime.getTime() + selectedPass.duration * 60000);
|
||
|
||
container.style.display = 'block';
|
||
document.getElementById('countdownSatName' + suffix).textContent = selectedPass.satellite;
|
||
|
||
if (now >= startTime && now <= endTime) {
|
||
// Currently visible
|
||
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));
|
||
const mins = Math.floor(remaining / 60);
|
||
const secs = remaining % 60;
|
||
|
||
document.getElementById('countdownToPass' + suffix).textContent = 'VISIBLE';
|
||
document.getElementById('countdownToPass' + suffix).classList.add('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = 'Now overhead';
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Remaining';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
document.getElementById('countdownStatus' + suffix).textContent = '🟢 SATELLITE CURRENTLY VISIBLE';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status visible';
|
||
|
||
} else if (startTime > now) {
|
||
// Upcoming pass
|
||
const secsToPass = Math.max(0, Math.floor((startTime - now) / 1000));
|
||
const hours = Math.floor(secsToPass / 3600);
|
||
const mins = Math.floor((secsToPass % 3600) / 60);
|
||
const secs = secsToPass % 60;
|
||
|
||
let countdownStr;
|
||
if (hours > 0) {
|
||
countdownStr = `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
} else {
|
||
countdownStr = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
document.getElementById('countdownToPass' + suffix).textContent = countdownStr;
|
||
document.getElementById('countdownToPass' + suffix).classList.remove('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
if (secsToPass < 300) {
|
||
document.getElementById('countdownStatus' + suffix).textContent = '🟡 PASS STARTING SOON';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status upcoming';
|
||
} else {
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'Selected pass';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
|
||
}
|
||
|
||
} else {
|
||
// Pass already happened
|
||
document.getElementById('countdownToPass' + suffix).textContent = 'PASSED';
|
||
document.getElementById('countdownToPass' + suffix).classList.remove('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'Pass has ended';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
|
||
}
|
||
}
|
||
|
||
// Parse pass time string to Date object
|
||
function parsePassTime(timeStr) {
|
||
// Expected format: "2025-12-21 14:32 UTC"
|
||
// Remove "UTC" suffix and parse as ISO-like format
|
||
const cleanTime = timeStr.replace(' UTC', '').replace(' ', 'T') + ':00Z';
|
||
const parsed = new Date(cleanTime);
|
||
|
||
// Fallback if that doesn't work
|
||
if (isNaN(parsed.getTime())) {
|
||
// Try parsing as-is
|
||
return new Date(timeStr.replace(' UTC', ''));
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
// Make sections collapsible
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||
h3.addEventListener('click', function () {
|
||
this.parentElement.classList.toggle('collapsed');
|
||
});
|
||
});
|
||
|
||
// Collapse all sections by default (except SDR Device which is first)
|
||
document.querySelectorAll('.section').forEach((section, index) => {
|
||
// Keep first section expanded, collapse rest
|
||
if (index > 0) {
|
||
section.classList.add('collapsed');
|
||
}
|
||
});
|
||
|
||
// Load bias-T setting from localStorage
|
||
loadBiasTSetting();
|
||
|
||
// Initialize observer location input fields from saved location
|
||
const obsLatInput = document.getElementById('obsLat');
|
||
const obsLonInput = document.getElementById('obsLon');
|
||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||
|
||
// Auto-connect to gpsd if available
|
||
autoConnectGps();
|
||
|
||
// Load pager message filters
|
||
loadPagerFilters();
|
||
|
||
// Initialize dropdown nav active state
|
||
updateDropdownActiveState();
|
||
});
|
||
|
||
// Toggle section collapse
|
||
function toggleSection(el) {
|
||
el.closest('.section').classList.toggle('collapsed');
|
||
}
|
||
|
||
// Dropdown navigation
|
||
function toggleNavDropdown(group) {
|
||
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${group}"]`);
|
||
const isOpen = dropdown.classList.contains('open');
|
||
|
||
// Close all dropdowns first
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
|
||
|
||
// Open this one if it was closed
|
||
if (!isOpen) {
|
||
dropdown.classList.add('open');
|
||
}
|
||
}
|
||
|
||
function closeAllDropdowns() {
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
|
||
}
|
||
|
||
function updateDropdownActiveState() {
|
||
// Map modes to their dropdown groups
|
||
const modeGroups = {
|
||
'pager': 'sdr', 'sensor': 'sdr',
|
||
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
|
||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||
'tscm': 'security'
|
||
};
|
||
|
||
// Remove has-active from all dropdowns
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('has-active'));
|
||
|
||
// Add has-active to the dropdown containing the current mode
|
||
const activeGroup = modeGroups[currentMode];
|
||
if (activeGroup) {
|
||
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${activeGroup}"]`);
|
||
if (dropdown) dropdown.classList.add('has-active');
|
||
}
|
||
}
|
||
|
||
// Close dropdowns when clicking outside
|
||
document.addEventListener('click', function (e) {
|
||
if (!e.target.closest('.mode-nav-dropdown')) {
|
||
closeAllDropdowns();
|
||
}
|
||
});
|
||
|
||
// Mode switching
|
||
function switchMode(mode) {
|
||
// Stop any running scans when switching modes
|
||
if (isRunning) stopDecoding();
|
||
if (isSensorRunning) stopSensorDecoding();
|
||
if (isWifiRunning) stopWifiScan();
|
||
if (isBtRunning) stopBtScan();
|
||
if (isAprsRunning) stopAprs();
|
||
if (isTscmRunning) stopTscmSweep();
|
||
|
||
currentMode = mode;
|
||
|
||
// Close dropdowns and update active state
|
||
closeAllDropdowns();
|
||
updateDropdownActiveState();
|
||
|
||
// Remove active from all nav buttons, then add to the correct one
|
||
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
|
||
const modeMap = {
|
||
'pager': 'pager', 'sensor': '433',
|
||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm'
|
||
};
|
||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||
const label = btn.querySelector('.nav-label');
|
||
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
|
||
btn.classList.add('active');
|
||
}
|
||
});
|
||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||
document.getElementById('aprsMode').classList.toggle('active', mode === 'aprs');
|
||
document.getElementById('tscmMode').classList.toggle('active', mode === 'tscm');
|
||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||
|
||
// Update header stats groups
|
||
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
|
||
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
|
||
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
|
||
|
||
// Show/hide dashboard buttons in nav bar
|
||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||
|
||
// Update active mode indicator
|
||
const modeNames = {
|
||
'pager': 'PAGER',
|
||
'sensor': '433MHZ',
|
||
'satellite': 'SATELLITE',
|
||
'wifi': 'WIFI',
|
||
'bluetooth': 'BLUETOOTH',
|
||
'listening': 'LISTENING POST',
|
||
'aprs': 'APRS',
|
||
'tscm': 'TSCM'
|
||
};
|
||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||
document.getElementById('aprsVisuals').style.display = mode === 'aprs' ? 'flex' : 'none';
|
||
document.getElementById('tscmVisuals').style.display = mode === 'tscm' ? 'flex' : 'none';
|
||
|
||
// Update output panel title based on mode
|
||
const titles = {
|
||
'pager': 'Pager Decoder',
|
||
'sensor': '433MHz Sensor Monitor',
|
||
'satellite': 'Satellite Monitor',
|
||
'wifi': 'WiFi Scanner',
|
||
'bluetooth': 'Bluetooth Scanner',
|
||
'listening': 'Listening Post',
|
||
'aprs': 'APRS Tracker',
|
||
'tscm': 'TSCM Counter-Surveillance'
|
||
};
|
||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||
|
||
// Initialize TSCM mode when selected
|
||
if (mode === 'tscm') {
|
||
loadTscmBaselines();
|
||
refreshTscmDevices();
|
||
}
|
||
|
||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||
const reconBtn = document.getElementById('reconBtn');
|
||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm') {
|
||
document.getElementById('reconPanel').style.display = 'none';
|
||
if (reconBtn) reconBtn.style.display = 'none';
|
||
if (intelBtn) intelBtn.style.display = 'none';
|
||
} else {
|
||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||
// Restore panel visibility based on reconEnabled state
|
||
if (reconEnabled) {
|
||
document.getElementById('reconPanel').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// Show RTL-SDR device section for modes that use it
|
||
document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'listening' || mode === 'aprs') ? 'block' : 'none';
|
||
|
||
// Toggle mode-specific tool status displays
|
||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||
|
||
// Hide waterfall and output console for modes with their own visualizations
|
||
// Pager waterfall: show only for pager mode
|
||
document.getElementById('pagerWaterfallPanel').style.display = (mode === 'pager') ? 'block' : 'none';
|
||
// Sensor waterfall: show only for sensor (433MHz) mode
|
||
document.getElementById('sensorWaterfallPanel').style.display = (mode === 'sensor') ? 'block' : 'none';
|
||
document.getElementById('output').style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm') ? 'none' : 'block';
|
||
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||
|
||
// Load interfaces and initialize visualizations when switching modes
|
||
if (mode === 'wifi') {
|
||
refreshWifiInterfaces();
|
||
initRadar();
|
||
initWatchList();
|
||
} else if (mode === 'bluetooth') {
|
||
refreshBtInterfaces();
|
||
initBtRadar();
|
||
} else if (mode === 'aprs') {
|
||
checkAprsTools();
|
||
initAprsMap();
|
||
// Fix map sizing on mobile after container becomes visible
|
||
setTimeout(() => {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
}, 100);
|
||
} else if (mode === 'satellite') {
|
||
initPolarPlot();
|
||
initSatelliteList();
|
||
}
|
||
}
|
||
|
||
// Handle window resize for maps (especially important on mobile orientation change)
|
||
window.addEventListener('resize', function () {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
});
|
||
|
||
// Also handle orientation changes explicitly for mobile
|
||
window.addEventListener('orientationchange', function () {
|
||
setTimeout(() => {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
}, 200);
|
||
});
|
||
|
||
// Track unique sensor devices
|
||
let uniqueDevices = new Set();
|
||
|
||
// Sensor frequency
|
||
function setSensorFreq(freq) {
|
||
document.getElementById('sensorFrequency').value = freq;
|
||
if (isSensorRunning) {
|
||
fetch('/stop_sensor', { method: 'POST' })
|
||
.then(() => setTimeout(() => startSensorDecoding(), 500));
|
||
}
|
||
}
|
||
|
||
// Start sensor decoding
|
||
function startSensorDecoding() {
|
||
const freq = document.getElementById('sensorFrequency').value;
|
||
const gain = document.getElementById('sensorGain').value;
|
||
const ppm = document.getElementById('sensorPpm').value;
|
||
const device = getSelectedDevice();
|
||
|
||
// Check if device is available
|
||
if (!checkDeviceAvailability('sensor')) {
|
||
return;
|
||
}
|
||
|
||
// Check for remote SDR
|
||
const remoteConfig = getRemoteSDRConfig();
|
||
if (remoteConfig === false) return; // Validation failed
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
ppm: ppm,
|
||
device: device,
|
||
sdr_type: getSelectedSDRType(),
|
||
bias_t: getBiasTEnabled()
|
||
};
|
||
|
||
// Add rtl_tcp params if using remote SDR
|
||
if (remoteConfig) {
|
||
config.rtl_tcp_host = remoteConfig.host;
|
||
config.rtl_tcp_port = remoteConfig.port;
|
||
}
|
||
|
||
fetch('/start_sensor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
reserveDevice(parseInt(device), 'sensor');
|
||
setSensorRunning(true);
|
||
startSensorStream();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Stop sensor decoding
|
||
function stopSensorDecoding() {
|
||
fetch('/stop_sensor', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
releaseDevice('sensor');
|
||
setSensorRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function setSensorRunning(running) {
|
||
isSensorRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
||
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
|
||
}
|
||
|
||
function startSensorStream() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
|
||
eventSource = new EventSource('/stream_sensor');
|
||
|
||
eventSource.onopen = function () {
|
||
showInfo('Sensor stream connected...');
|
||
};
|
||
|
||
eventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'sensor') {
|
||
addSensorReading(data);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setSensorRunning(false);
|
||
}
|
||
} else if (data.type === 'info' || data.type === 'raw') {
|
||
showInfo(data.text);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = function (e) {
|
||
console.error('Sensor stream error');
|
||
};
|
||
}
|
||
|
||
function addSensorReading(data) {
|
||
const output = document.getElementById('output');
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Store for export
|
||
allMessages.push(data);
|
||
playAlert();
|
||
pulseSignal();
|
||
addWaterfallPoint(Date.now(), 0.8);
|
||
|
||
sensorCount++;
|
||
document.getElementById('sensorCount').textContent = sensorCount;
|
||
|
||
// Track unique devices by model + id
|
||
const deviceKey = (data.model || 'Unknown') + '_' + (data.id || data.channel || '0');
|
||
if (!uniqueDevices.has(deviceKey)) {
|
||
uniqueDevices.add(deviceKey);
|
||
document.getElementById('deviceCount').textContent = uniqueDevices.size;
|
||
}
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'sensor-card';
|
||
|
||
let dataItems = '';
|
||
const skipKeys = ['type', 'time', 'model', 'raw'];
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (!skipKeys.includes(key) && value !== null && value !== undefined) {
|
||
const label = key.replace(/_/g, ' ');
|
||
let displayValue = value;
|
||
if (key === 'temperature_C') displayValue = value + ' °C';
|
||
else if (key === 'temperature_F') displayValue = value + ' °F';
|
||
else if (key === 'humidity') displayValue = value + ' %';
|
||
else if (key === 'pressure_hPa') displayValue = value + ' hPa';
|
||
else if (key === 'wind_avg_km_h') displayValue = value + ' km/h';
|
||
else if (key === 'rain_mm') displayValue = value + ' mm';
|
||
else if (key === 'battery_ok') displayValue = value ? 'OK' : 'Low';
|
||
|
||
dataItems += '<div class="data-item"><div class="data-label">' + label + '</div><div class="data-value">' + displayValue + '</div></div>';
|
||
}
|
||
}
|
||
|
||
const relTime = data.time ? getRelativeTime(data.time.split(' ')[1] || data.time) : 'now';
|
||
|
||
card.innerHTML =
|
||
'<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">' +
|
||
'<span class="device-name">' + (data.model || 'Unknown Device') + '</span>' +
|
||
'<span class="msg-time" data-timestamp="' + (data.time || '') + '" style="color: #444; font-size: 10px;">' + relTime + '</span>' +
|
||
'</div>' +
|
||
'<div class="sensor-data">' + dataItems + '</div>';
|
||
|
||
output.insertBefore(card, output.firstChild);
|
||
|
||
if (autoScroll) output.scrollTop = 0;
|
||
while (output.children.length > 100) {
|
||
output.removeChild(output.lastChild);
|
||
}
|
||
}
|
||
|
||
function toggleSensorLogging() {
|
||
const enabled = document.getElementById('sensorLogging').checked;
|
||
fetch('/logging', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enabled, log_file: 'sensor_data.log' })
|
||
});
|
||
}
|
||
|
||
// NOTE: Audio alert settings moved to static/js/core/audio.js
|
||
|
||
// Message storage for export
|
||
let allMessages = [];
|
||
|
||
function exportCSV() {
|
||
if (allMessages.length === 0) {
|
||
alert('No messages to export');
|
||
return;
|
||
}
|
||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||
const csv = [headers.join(',')];
|
||
allMessages.forEach(msg => {
|
||
const row = [
|
||
msg.timestamp || '',
|
||
msg.protocol || '',
|
||
msg.address || '',
|
||
msg.function || '',
|
||
msg.msg_type || '',
|
||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||
];
|
||
csv.push(row.join(','));
|
||
});
|
||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||
}
|
||
|
||
function exportJSON() {
|
||
if (allMessages.length === 0) {
|
||
alert('No messages to export');
|
||
return;
|
||
}
|
||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||
}
|
||
|
||
function downloadFile(content, filename, type) {
|
||
const blob = new Blob([content], { type });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Auto-scroll setting
|
||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||
|
||
function toggleAutoScroll() {
|
||
autoScroll = !autoScroll;
|
||
localStorage.setItem('autoScroll', autoScroll);
|
||
updateAutoScrollButton();
|
||
}
|
||
|
||
function updateAutoScrollButton() {
|
||
const btn = document.getElementById('autoScrollBtn');
|
||
if (btn) {
|
||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||
btn.classList.toggle('active', autoScroll);
|
||
}
|
||
}
|
||
|
||
// Signal activity meter
|
||
let signalActivity = 0;
|
||
let lastMessageTime = 0;
|
||
|
||
function updateSignalMeter() {
|
||
const now = Date.now();
|
||
const timeSinceLastMsg = now - lastMessageTime;
|
||
|
||
// Decay signal activity over time
|
||
if (timeSinceLastMsg > 1000) {
|
||
signalActivity = Math.max(0, signalActivity - 0.05);
|
||
}
|
||
|
||
const meter = document.getElementById('signalMeter');
|
||
const bars = meter?.querySelectorAll('.signal-bar');
|
||
if (bars) {
|
||
const activeBars = Math.ceil(signalActivity * bars.length);
|
||
bars.forEach((bar, i) => {
|
||
bar.classList.toggle('active', i < activeBars);
|
||
});
|
||
}
|
||
}
|
||
|
||
function pulseSignal() {
|
||
signalActivity = Math.min(1, signalActivity + 0.4);
|
||
lastMessageTime = Date.now();
|
||
|
||
// Flash waterfall canvas (use appropriate canvas based on mode)
|
||
const canvasId = (currentMode === 'sensor') ? 'sensorWaterfallCanvas' : 'waterfallCanvas';
|
||
const canvas = document.getElementById(canvasId);
|
||
if (canvas) {
|
||
canvas.classList.add('active');
|
||
setTimeout(() => canvas.classList.remove('active'), 500);
|
||
}
|
||
}
|
||
|
||
// Waterfall display
|
||
const waterfallData = [];
|
||
const maxWaterfallRows = 50;
|
||
|
||
function addWaterfallPoint(timestamp, intensity) {
|
||
waterfallData.push({ time: timestamp, intensity });
|
||
if (waterfallData.length > maxWaterfallRows * 100) {
|
||
waterfallData.shift();
|
||
}
|
||
renderWaterfall();
|
||
}
|
||
|
||
function renderWaterfall() {
|
||
// Render to the appropriate canvas based on current mode
|
||
const canvasId = (currentMode === 'sensor') ? 'sensorWaterfallCanvas' : 'waterfallCanvas';
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
|
||
// Shift existing image down
|
||
const imageData = ctx.getImageData(0, 0, width, height - 2);
|
||
ctx.putImageData(imageData, 0, 2);
|
||
|
||
// Draw new row at top
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(0, 0, width, 2);
|
||
|
||
// Add activity markers
|
||
const now = Date.now();
|
||
const recentData = waterfallData.filter(d => now - d.time < 100);
|
||
recentData.forEach(d => {
|
||
const x = Math.random() * width;
|
||
const hue = 180 + (d.intensity * 60); // cyan to green
|
||
ctx.fillStyle = `hsla(${hue}, 100%, 50%, ${d.intensity})`;
|
||
ctx.fillRect(x - 2, 0, 4, 2);
|
||
});
|
||
}
|
||
|
||
// Relative timestamps
|
||
function getRelativeTime(timestamp) {
|
||
if (!timestamp) return '';
|
||
const now = new Date();
|
||
const parts = timestamp.split(':');
|
||
const msgTime = new Date();
|
||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||
|
||
const diff = Math.floor((now - msgTime) / 1000);
|
||
if (diff < 5) return 'just now';
|
||
if (diff < 60) return diff + 's ago';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
return timestamp;
|
||
}
|
||
|
||
function updateRelativeTimes() {
|
||
document.querySelectorAll('.msg-time').forEach(el => {
|
||
const ts = el.dataset.timestamp;
|
||
if (ts) el.textContent = getRelativeTime(ts);
|
||
});
|
||
}
|
||
|
||
// Update timers
|
||
setInterval(updateSignalMeter, 100);
|
||
setInterval(updateRelativeTimes, 10000);
|
||
|
||
// Default presets (UK frequencies)
|
||
const defaultPresets = ['153.350', '153.025'];
|
||
|
||
// Load presets from localStorage or use defaults
|
||
function loadPresets() {
|
||
const saved = localStorage.getItem('pagerPresets');
|
||
return saved ? JSON.parse(saved) : [...defaultPresets];
|
||
}
|
||
|
||
function savePresets(presets) {
|
||
localStorage.setItem('pagerPresets', JSON.stringify(presets));
|
||
}
|
||
|
||
function renderPresets() {
|
||
const presets = loadPresets();
|
||
const container = document.getElementById('presetButtons');
|
||
container.innerHTML = presets.map(freq =>
|
||
`<button class="preset-btn" onclick="setFreq('${freq}')" oncontextmenu="removePreset('${freq}'); return false;" title="Right-click to remove">${freq}</button>`
|
||
).join('');
|
||
}
|
||
|
||
function addPreset() {
|
||
const input = document.getElementById('newPresetFreq');
|
||
const freq = input.value.trim();
|
||
if (!freq || isNaN(parseFloat(freq))) {
|
||
alert('Please enter a valid frequency');
|
||
return;
|
||
}
|
||
const presets = loadPresets();
|
||
if (!presets.includes(freq)) {
|
||
presets.push(freq);
|
||
savePresets(presets);
|
||
renderPresets();
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
function removePreset(freq) {
|
||
if (confirm('Remove preset ' + freq + ' MHz?')) {
|
||
let presets = loadPresets();
|
||
presets = presets.filter(p => p !== freq);
|
||
savePresets(presets);
|
||
renderPresets();
|
||
}
|
||
}
|
||
|
||
function resetPresets() {
|
||
if (confirm('Reset to default presets?')) {
|
||
savePresets([...defaultPresets]);
|
||
renderPresets();
|
||
}
|
||
}
|
||
|
||
// Initialize presets on load
|
||
renderPresets();
|
||
|
||
// Initialize button states on load
|
||
updateMuteButton();
|
||
updateAutoScrollButton();
|
||
|
||
// NOTE: Audio context initialization moved to static/js/core/audio.js
|
||
|
||
function setFreq(freq) {
|
||
document.getElementById('frequency').value = freq;
|
||
// Auto-restart decoder with new frequency if currently running
|
||
if (isRunning) {
|
||
fetch('/stop', { method: 'POST' })
|
||
.then(() => {
|
||
setTimeout(() => startDecoding(), 500);
|
||
});
|
||
}
|
||
}
|
||
|
||
// SDR hardware capabilities
|
||
const sdrCapabilities = {
|
||
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
|
||
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
|
||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 }
|
||
};
|
||
|
||
// Current device list with SDR type info
|
||
let currentDeviceList = [];
|
||
|
||
// SDR Device Usage Tracking
|
||
// Tracks which mode is using which device index
|
||
const sdrDeviceUsage = {
|
||
// deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner')
|
||
};
|
||
|
||
function getDeviceInUseBy(deviceIndex) {
|
||
return sdrDeviceUsage[deviceIndex] || null;
|
||
}
|
||
|
||
function isDeviceInUse(deviceIndex) {
|
||
return sdrDeviceUsage[deviceIndex] !== undefined;
|
||
}
|
||
|
||
function reserveDevice(deviceIndex, modeName) {
|
||
sdrDeviceUsage[deviceIndex] = modeName;
|
||
updateDeviceSelectStatus();
|
||
}
|
||
|
||
function releaseDevice(modeName) {
|
||
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) {
|
||
if (mode === modeName) {
|
||
delete sdrDeviceUsage[idx];
|
||
}
|
||
}
|
||
updateDeviceSelectStatus();
|
||
}
|
||
|
||
function getAvailableDevice() {
|
||
// Find first device not in use
|
||
for (const device of currentDeviceList) {
|
||
if (!isDeviceInUse(device.index)) {
|
||
return device.index;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function updateDeviceSelectStatus() {
|
||
// Update device dropdown to show which devices are in use
|
||
const select = document.getElementById('deviceSelect');
|
||
if (!select) return;
|
||
|
||
const options = select.querySelectorAll('option');
|
||
options.forEach(opt => {
|
||
const idx = parseInt(opt.value);
|
||
const usedBy = getDeviceInUseBy(idx);
|
||
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
|
||
if (usedBy) {
|
||
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;
|
||
opt.style.color = 'var(--accent-orange)';
|
||
} else {
|
||
opt.textContent = baseName;
|
||
opt.style.color = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function checkDeviceAvailability(modeName) {
|
||
const selectedDevice = parseInt(getSelectedDevice());
|
||
const usedBy = getDeviceInUseBy(selectedDevice);
|
||
|
||
if (usedBy && usedBy !== modeName) {
|
||
// Device is in use by another mode
|
||
const availableDevice = getAvailableDevice();
|
||
|
||
if (availableDevice !== null) {
|
||
// Another device is available - offer to switch
|
||
const switchDevice = confirm(
|
||
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}.\n\n` +
|
||
`Device ${availableDevice} is available. Switch to it?`
|
||
);
|
||
if (switchDevice) {
|
||
document.getElementById('deviceSelect').value = availableDevice;
|
||
return true; // Can proceed with new device
|
||
}
|
||
return false; // User declined to switch
|
||
} else {
|
||
// No other devices available
|
||
showNotification('SDR In Use',
|
||
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}. ` +
|
||
`No other SDR devices available. Stop ${usedBy} first or connect another SDR.`
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
return true; // Device is available
|
||
}
|
||
|
||
function onSDRTypeChanged() {
|
||
const sdrType = document.getElementById('sdrTypeSelect').value;
|
||
const select = document.getElementById('deviceSelect');
|
||
|
||
// Filter devices by selected SDR type
|
||
const filteredDevices = currentDeviceList.filter(d =>
|
||
(d.sdr_type || 'rtlsdr') === sdrType
|
||
);
|
||
|
||
if (filteredDevices.length === 0) {
|
||
select.innerHTML = `<option value="0">No ${sdrCapabilities[sdrType]?.name || sdrType} devices found</option>`;
|
||
} else {
|
||
select.innerHTML = filteredDevices.map(d =>
|
||
`<option value="${d.index}" data-sdr-type="${d.sdr_type || 'rtlsdr'}">${d.index}: ${d.name}</option>`
|
||
).join('');
|
||
}
|
||
|
||
// Update capabilities display
|
||
updateCapabilitiesDisplay(sdrType);
|
||
}
|
||
|
||
function updateCapabilitiesDisplay(sdrType) {
|
||
const caps = sdrCapabilities[sdrType];
|
||
if (caps) {
|
||
document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`;
|
||
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
|
||
}
|
||
}
|
||
|
||
function refreshDevices() {
|
||
fetch('/devices')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
// Store full device list with SDR type info
|
||
currentDeviceList = devices;
|
||
deviceList = devices;
|
||
|
||
// Auto-select SDR type if devices found
|
||
if (devices.length > 0) {
|
||
const firstType = devices[0].sdr_type || 'rtlsdr';
|
||
document.getElementById('sdrTypeSelect').value = firstType;
|
||
}
|
||
|
||
// Trigger filter update
|
||
onSDRTypeChanged();
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to refresh devices:', err);
|
||
const select = document.getElementById('deviceSelect');
|
||
select.innerHTML = '<option value="0">Error loading devices</option>';
|
||
});
|
||
}
|
||
|
||
function getSelectedDevice() {
|
||
return document.getElementById('deviceSelect').value;
|
||
}
|
||
|
||
function getSelectedSDRType() {
|
||
return document.getElementById('sdrTypeSelect').value;
|
||
}
|
||
|
||
// Bias-T power setting
|
||
function saveBiasTSetting() {
|
||
const enabled = document.getElementById('biasT')?.checked || false;
|
||
localStorage.setItem('biasTEnabled', enabled);
|
||
}
|
||
|
||
function getBiasTEnabled() {
|
||
return document.getElementById('biasT')?.checked || false;
|
||
}
|
||
|
||
function loadBiasTSetting() {
|
||
const saved = localStorage.getItem('biasTEnabled');
|
||
if (saved === 'true') {
|
||
const checkbox = document.getElementById('biasT');
|
||
if (checkbox) checkbox.checked = true;
|
||
}
|
||
}
|
||
|
||
function toggleRemoteSDR() {
|
||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||
const configDiv = document.getElementById('remoteSDRConfig');
|
||
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
|
||
|
||
configDiv.style.display = useRemote ? 'block' : 'none';
|
||
|
||
// Dim local device controls when using remote
|
||
localControls.forEach(el => {
|
||
el.style.opacity = useRemote ? '0.5' : '1';
|
||
el.disabled = useRemote;
|
||
});
|
||
}
|
||
|
||
function getRemoteSDRConfig() {
|
||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||
if (!useRemote) return null;
|
||
|
||
const host = document.getElementById('rtlTcpHost').value.trim();
|
||
const port = parseInt(document.getElementById('rtlTcpPort').value) || 1234;
|
||
|
||
if (!host) {
|
||
alert('Please enter rtl_tcp host address');
|
||
return false;
|
||
}
|
||
|
||
return { host, port };
|
||
}
|
||
|
||
function getSelectedProtocols() {
|
||
const protocols = [];
|
||
if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512');
|
||
if (document.getElementById('proto_pocsag1200').checked) protocols.push('POCSAG1200');
|
||
if (document.getElementById('proto_pocsag2400').checked) protocols.push('POCSAG2400');
|
||
if (document.getElementById('proto_flex').checked) protocols.push('FLEX');
|
||
return protocols;
|
||
}
|
||
|
||
function startDecoding() {
|
||
const freq = document.getElementById('frequency').value;
|
||
const gain = document.getElementById('gain').value;
|
||
const squelch = document.getElementById('squelch').value;
|
||
const ppm = document.getElementById('ppm').value;
|
||
const device = getSelectedDevice();
|
||
const protocols = getSelectedProtocols();
|
||
|
||
if (protocols.length === 0) {
|
||
alert('Please select at least one protocol');
|
||
return;
|
||
}
|
||
|
||
// Check if device is available
|
||
if (!checkDeviceAvailability('pager')) {
|
||
return;
|
||
}
|
||
|
||
// Check for remote SDR
|
||
const remoteConfig = getRemoteSDRConfig();
|
||
if (remoteConfig === false) return; // Validation failed
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
squelch: squelch,
|
||
ppm: ppm,
|
||
device: device,
|
||
sdr_type: getSelectedSDRType(),
|
||
protocols: protocols,
|
||
bias_t: getBiasTEnabled()
|
||
};
|
||
|
||
// Add rtl_tcp params if using remote SDR
|
||
if (remoteConfig) {
|
||
config.rtl_tcp_host = remoteConfig.host;
|
||
config.rtl_tcp_port = remoteConfig.port;
|
||
}
|
||
|
||
fetch('/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
reserveDevice(parseInt(device), 'pager');
|
||
setRunning(true);
|
||
startStream();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Start error:', err);
|
||
});
|
||
}
|
||
|
||
function stopDecoding() {
|
||
fetch('/stop', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
releaseDevice('pager');
|
||
setRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function killAll() {
|
||
fetch('/killall', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Release all devices
|
||
Object.keys(sdrDeviceUsage).forEach(idx => delete sdrDeviceUsage[idx]);
|
||
updateDeviceSelectStatus();
|
||
|
||
setRunning(false);
|
||
setSensorRunning(false);
|
||
isScannerRunning = false;
|
||
isAudioPlaying = false;
|
||
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
showInfo('Killed all processes: ' + (data.processes.length ? data.processes.join(', ') : 'none running'));
|
||
});
|
||
}
|
||
|
||
function checkStatus() {
|
||
fetch('/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.running !== isRunning) {
|
||
setRunning(data.running);
|
||
if (data.running && !eventSource) {
|
||
startStream();
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Silently ignore - server may be restarting or network issue
|
||
});
|
||
}
|
||
|
||
// Periodic status check every 5 seconds
|
||
setInterval(checkStatus, 5000);
|
||
|
||
function toggleLogging() {
|
||
const enabled = document.getElementById('loggingEnabled').checked;
|
||
const logFile = document.getElementById('logFilePath').value;
|
||
fetch('/logging', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enabled, log_file: logFile })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
showInfo(data.logging ? 'Logging enabled: ' + data.log_file : 'Logging disabled');
|
||
});
|
||
}
|
||
|
||
function setRunning(running) {
|
||
isRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
||
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
||
}
|
||
|
||
function startStream() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
|
||
eventSource = new EventSource('/stream');
|
||
|
||
eventSource.onopen = function () {
|
||
showInfo('Stream connected...');
|
||
};
|
||
|
||
eventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (data.type === 'message') {
|
||
addMessage(data);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setRunning(false);
|
||
} else if (data.text === 'started') {
|
||
showInfo('Decoder started, waiting for signals...');
|
||
}
|
||
} else if (data.type === 'info') {
|
||
showInfo(data.text);
|
||
} else if (data.type === 'raw') {
|
||
showInfo(data.text);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = function (e) {
|
||
checkStatus();
|
||
};
|
||
}
|
||
|
||
function addMessage(msg) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Store message for export (always, even if filtered)
|
||
allMessages.push(msg);
|
||
|
||
// Check if message should be filtered from display
|
||
const isFiltered = shouldFilterMessage(msg);
|
||
|
||
// Update counts (always, even if filtered)
|
||
msgCount++;
|
||
document.getElementById('msgCount').textContent = msgCount;
|
||
|
||
let protoClass = '';
|
||
if (msg.protocol.includes('POCSAG')) {
|
||
pocsagCount++;
|
||
protoClass = 'pocsag';
|
||
document.getElementById('pocsagCount').textContent = pocsagCount;
|
||
} else if (msg.protocol.includes('FLEX')) {
|
||
flexCount++;
|
||
protoClass = 'flex';
|
||
document.getElementById('flexCount').textContent = flexCount;
|
||
}
|
||
|
||
// If filtered, skip display but update filtered count
|
||
if (isFiltered) {
|
||
filteredCount++;
|
||
return;
|
||
}
|
||
|
||
// Play audio alert (only for non-filtered messages)
|
||
playAlert();
|
||
|
||
// Update signal meter
|
||
pulseSignal();
|
||
|
||
// Add to waterfall
|
||
addWaterfallPoint(Date.now(), 0.8);
|
||
|
||
const isNumeric = /^[0-9\s\-\*\#U]+$/.test(msg.message);
|
||
const relativeTime = getRelativeTime(msg.timestamp);
|
||
|
||
const msgEl = document.createElement('div');
|
||
msgEl.className = 'message ' + protoClass;
|
||
msgEl.innerHTML = `
|
||
<div class="header">
|
||
<span class="protocol">${escapeHtml(msg.protocol)}</span>
|
||
<span class="msg-time" data-timestamp="${escapeAttr(msg.timestamp)}" title="${escapeAttr(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
|
||
</div>
|
||
<div class="address">Address: ${escapeHtml(msg.address)}${msg.function ? ' | Func: ' + escapeHtml(msg.function) : ''}</div>
|
||
<div class="content ${isNumeric ? 'numeric' : ''}">${escapeHtml(msg.message)}</div>
|
||
`;
|
||
|
||
output.insertBefore(msgEl, output.firstChild);
|
||
|
||
// Auto-scroll to top (newest messages)
|
||
if (autoScroll) {
|
||
output.scrollTop = 0;
|
||
}
|
||
|
||
// Limit messages displayed
|
||
while (output.children.length > 100) {
|
||
output.removeChild(output.lastChild);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
// Escape for use in HTML attributes (especially onclick handlers)
|
||
if (text === null || text === undefined) return '';
|
||
var s = String(text);
|
||
s = s.replace(/&/g, '&');
|
||
s = s.replace(/'/g, ''');
|
||
s = s.replace(/"/g, '"');
|
||
s = s.replace(/</g, '<');
|
||
s = s.replace(/>/g, '>');
|
||
return s;
|
||
}
|
||
|
||
function isValidMac(mac) {
|
||
// Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||
}
|
||
|
||
function isValidChannel(ch) {
|
||
// Validate WiFi channel (1-200 covers all bands)
|
||
const num = parseInt(ch, 10);
|
||
return !isNaN(num) && num >= 1 && num <= 200;
|
||
}
|
||
|
||
function showInfo(text) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Clear placeholder only (has the 'placeholder' class)
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
const infoEl = document.createElement('div');
|
||
infoEl.className = 'info-msg';
|
||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||
infoEl.textContent = text;
|
||
output.insertBefore(infoEl, output.firstChild);
|
||
}
|
||
|
||
function showError(text) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Clear placeholder only (has the 'placeholder' class)
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
const errorEl = document.createElement('div');
|
||
errorEl.className = 'error-msg';
|
||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||
errorEl.textContent = '⚠ ' + text;
|
||
output.insertBefore(errorEl, output.firstChild);
|
||
}
|
||
|
||
function clearMessages() {
|
||
document.getElementById('output').innerHTML = `
|
||
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
|
||
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
|
||
</div>
|
||
`;
|
||
msgCount = 0;
|
||
pocsagCount = 0;
|
||
flexCount = 0;
|
||
sensorCount = 0;
|
||
filteredCount = 0;
|
||
allMessages = [];
|
||
uniqueDevices.clear();
|
||
document.getElementById('msgCount').textContent = '0';
|
||
document.getElementById('pocsagCount').textContent = '0';
|
||
document.getElementById('flexCount').textContent = '0';
|
||
document.getElementById('sensorCount').textContent = '0';
|
||
document.getElementById('deviceCount').textContent = '0';
|
||
|
||
// Reset recon data
|
||
deviceDatabase.clear();
|
||
newDeviceAlerts = 0;
|
||
anomalyAlerts = 0;
|
||
document.getElementById('trackedCount').textContent = '0';
|
||
document.getElementById('newDeviceCount').textContent = '0';
|
||
document.getElementById('anomalyCount').textContent = '0';
|
||
document.getElementById('reconContent').innerHTML = '<div style="color: #444; text-align: center; padding: 30px; font-size: 11px;">Device intelligence data will appear here as signals are intercepted.</div>';
|
||
}
|
||
|
||
// ============== DEVICE INTELLIGENCE & RECONNAISSANCE ==============
|
||
|
||
// Device tracking database
|
||
const deviceDatabase = new Map(); // key: deviceId, value: device profile
|
||
// Default to true if not set, so device intelligence works by default
|
||
let reconEnabled = localStorage.getItem('reconEnabled') !== 'false';
|
||
let newDeviceAlerts = 0;
|
||
let anomalyAlerts = 0;
|
||
|
||
// Device profile structure
|
||
function createDeviceProfile(deviceId, protocol, firstSeen) {
|
||
return {
|
||
id: deviceId,
|
||
protocol: protocol,
|
||
firstSeen: firstSeen,
|
||
lastSeen: firstSeen,
|
||
transmissionCount: 1,
|
||
transmissions: [firstSeen], // timestamps of recent transmissions
|
||
avgInterval: null, // average time between transmissions
|
||
addresses: new Set(),
|
||
models: new Set(),
|
||
messages: [],
|
||
isNew: true,
|
||
anomalies: [],
|
||
signalStrength: [],
|
||
encrypted: null // null = unknown, true/false
|
||
};
|
||
}
|
||
|
||
// Analyze transmission patterns for anomalies
|
||
function analyzeTransmissions(profile) {
|
||
const anomalies = [];
|
||
const now = Date.now();
|
||
|
||
// Need at least 3 transmissions to analyze patterns
|
||
if (profile.transmissions.length < 3) {
|
||
return anomalies;
|
||
}
|
||
|
||
// Calculate intervals between transmissions
|
||
const intervals = [];
|
||
for (let i = 1; i < profile.transmissions.length; i++) {
|
||
intervals.push(profile.transmissions[i] - profile.transmissions[i - 1]);
|
||
}
|
||
|
||
// Calculate average and standard deviation
|
||
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||
profile.avgInterval = avg;
|
||
|
||
const variance = intervals.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / intervals.length;
|
||
const stdDev = Math.sqrt(variance);
|
||
|
||
// Check for burst transmission (sudden increase in frequency)
|
||
const lastInterval = intervals[intervals.length - 1];
|
||
if (avg > 0 && lastInterval < avg * 0.2) {
|
||
anomalies.push({
|
||
type: 'burst',
|
||
severity: 'medium',
|
||
message: 'Burst transmission detected - interval ' + Math.round(lastInterval / 1000) + 's vs avg ' + Math.round(avg / 1000) + 's'
|
||
});
|
||
}
|
||
|
||
// Check for silence break (device was quiet, now transmitting again)
|
||
if (avg > 0 && lastInterval > avg * 5) {
|
||
anomalies.push({
|
||
type: 'silence_break',
|
||
severity: 'low',
|
||
message: 'Device resumed after ' + Math.round(lastInterval / 60000) + ' min silence'
|
||
});
|
||
}
|
||
|
||
return anomalies;
|
||
}
|
||
|
||
// Check for encryption indicators
|
||
function detectEncryption(message) {
|
||
if (!message || message === '[No Message]' || message === '[Tone Only]') {
|
||
return null; // Can't determine
|
||
}
|
||
|
||
// Check for high entropy (random-looking data)
|
||
const printableRatio = (message.match(/[a-zA-Z0-9\s.,!?-]/g) || []).length / message.length;
|
||
|
||
// Check for common encrypted patterns (hex strings, base64-like)
|
||
const hexPattern = /^[0-9A-Fa-f\s]+$/;
|
||
const hasNonPrintable = /[^\x20-\x7E]/.test(message);
|
||
|
||
if (printableRatio > 0.8 && !hasNonPrintable) {
|
||
return false; // Likely plaintext
|
||
} else if (hexPattern.test(message.replace(/\s/g, '')) || hasNonPrintable) {
|
||
return true; // Likely encrypted or encoded
|
||
}
|
||
|
||
return null; // Unknown
|
||
}
|
||
|
||
// Generate device fingerprint
|
||
function generateDeviceId(data) {
|
||
if (data.protocol && data.protocol.includes('POCSAG')) {
|
||
return 'PAGER_' + (data.address || 'UNK');
|
||
} else if (data.protocol === 'FLEX') {
|
||
return 'FLEX_' + (data.address || 'UNK');
|
||
} else if (data.protocol === 'WiFi-AP') {
|
||
return 'WIFI_AP_' + (data.address || 'UNK').replace(/:/g, '');
|
||
} else if (data.protocol === 'WiFi-Client') {
|
||
return 'WIFI_CLIENT_' + (data.address || 'UNK').replace(/:/g, '');
|
||
} else if (data.protocol === 'Bluetooth' || data.protocol === 'BLE') {
|
||
return 'BT_' + (data.address || 'UNK').replace(/:/g, '');
|
||
} else if (data.model) {
|
||
// 433MHz sensor
|
||
const id = data.id || data.channel || data.unit || '0';
|
||
return 'SENSOR_' + data.model.replace(/\s+/g, '_') + '_' + id;
|
||
}
|
||
return 'UNKNOWN_' + Date.now();
|
||
}
|
||
|
||
// Track a device transmission
|
||
function trackDevice(data) {
|
||
const now = Date.now();
|
||
const deviceId = generateDeviceId(data);
|
||
const protocol = data.protocol || data.model || 'Unknown';
|
||
|
||
let profile = deviceDatabase.get(deviceId);
|
||
let isNewDevice = false;
|
||
|
||
if (!profile) {
|
||
// New device discovered
|
||
profile = createDeviceProfile(deviceId, protocol, now);
|
||
isNewDevice = true;
|
||
newDeviceAlerts++;
|
||
document.getElementById('newDeviceCount').textContent = newDeviceAlerts;
|
||
} else {
|
||
// Update existing profile
|
||
profile.lastSeen = now;
|
||
profile.transmissionCount++;
|
||
profile.transmissions.push(now);
|
||
profile.isNew = false;
|
||
|
||
// Keep only last 100 transmissions for analysis
|
||
if (profile.transmissions.length > 100) {
|
||
profile.transmissions = profile.transmissions.slice(-100);
|
||
}
|
||
}
|
||
|
||
// Track addresses
|
||
if (data.address) profile.addresses.add(data.address);
|
||
if (data.model) profile.models.add(data.model);
|
||
|
||
// Store recent messages (keep last 10)
|
||
if (data.message) {
|
||
profile.messages.unshift({
|
||
text: data.message,
|
||
time: now
|
||
});
|
||
if (profile.messages.length > 10) profile.messages.pop();
|
||
|
||
// Detect encryption
|
||
const encrypted = detectEncryption(data.message);
|
||
if (encrypted !== null) profile.encrypted = encrypted;
|
||
}
|
||
|
||
// Analyze for anomalies
|
||
const newAnomalies = analyzeTransmissions(profile);
|
||
if (newAnomalies.length > 0) {
|
||
profile.anomalies = profile.anomalies.concat(newAnomalies);
|
||
anomalyAlerts += newAnomalies.length;
|
||
document.getElementById('anomalyCount').textContent = anomalyAlerts;
|
||
}
|
||
|
||
deviceDatabase.set(deviceId, profile);
|
||
document.getElementById('trackedCount').textContent = deviceDatabase.size;
|
||
|
||
// Update recon display
|
||
if (reconEnabled) {
|
||
updateReconDisplay(deviceId, profile, isNewDevice, newAnomalies);
|
||
}
|
||
|
||
return { deviceId, profile, isNewDevice, anomalies: newAnomalies };
|
||
}
|
||
|
||
// Update reconnaissance display
|
||
function updateReconDisplay(deviceId, profile, isNewDevice, anomalies) {
|
||
const content = document.getElementById('reconContent');
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = content.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Check if device row already exists
|
||
let row = document.getElementById('device_' + deviceId.replace(/[^a-zA-Z0-9]/g, '_'));
|
||
|
||
if (!row) {
|
||
// Create new row
|
||
row = document.createElement('div');
|
||
row.id = 'device_' + deviceId.replace(/[^a-zA-Z0-9]/g, '_');
|
||
row.className = 'device-row' + (isNewDevice ? ' new-device' : '');
|
||
content.insertBefore(row, content.firstChild);
|
||
}
|
||
|
||
// Determine protocol badge class
|
||
let badgeClass = 'proto-unknown';
|
||
if (profile.protocol.includes('POCSAG')) badgeClass = 'proto-pocsag';
|
||
else if (profile.protocol === 'FLEX') badgeClass = 'proto-flex';
|
||
else if (profile.protocol.includes('SENSOR') || profile.models.size > 0) badgeClass = 'proto-433';
|
||
|
||
// Calculate transmission rate bar width
|
||
const maxRate = 100; // Max expected transmissions
|
||
const rateWidth = Math.min(100, (profile.transmissionCount / maxRate) * 100);
|
||
|
||
// Determine timeline status
|
||
const timeSinceLast = Date.now() - profile.lastSeen;
|
||
let timelineDot = 'recent';
|
||
if (timeSinceLast > 300000) timelineDot = 'old'; // > 5 min
|
||
else if (timeSinceLast > 60000) timelineDot = 'stale'; // > 1 min
|
||
|
||
// Build encryption indicator
|
||
let encStatus = 'Unknown';
|
||
let encClass = '';
|
||
if (profile.encrypted === true) { encStatus = 'Encrypted'; encClass = 'encrypted'; }
|
||
else if (profile.encrypted === false) { encStatus = 'Plaintext'; encClass = 'plaintext'; }
|
||
|
||
// Format time
|
||
const lastSeenStr = getRelativeTime(new Date(profile.lastSeen).toTimeString().split(' ')[0]);
|
||
const firstSeenStr = new Date(profile.firstSeen).toLocaleTimeString();
|
||
|
||
// Update row content
|
||
row.className = 'device-row' + (isNewDevice ? ' new-device' : '') + (anomalies.length > 0 ? ' anomaly' : '');
|
||
row.innerHTML = `
|
||
<div class="device-info">
|
||
<div class="device-name-row">
|
||
<span class="timeline-dot ${timelineDot}"></span>
|
||
<span class="badge ${badgeClass}">${profile.protocol.substring(0, 10)}</span>
|
||
${deviceId.substring(0, 30)}
|
||
</div>
|
||
<div class="device-id">
|
||
First: ${firstSeenStr} | Last: ${lastSeenStr} | TX: ${profile.transmissionCount}
|
||
${profile.avgInterval ? ' | Interval: ' + Math.round(profile.avgInterval / 1000) + 's' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="device-meta ${encClass}">${encStatus}</div>
|
||
<div>
|
||
<div class="transmission-bar">
|
||
<div class="transmission-bar-fill" style="width: ${rateWidth}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="device-meta">${Array.from(profile.addresses).slice(0, 2).join(', ')}</div>
|
||
`;
|
||
|
||
// Show anomaly alerts
|
||
if (anomalies.length > 0) {
|
||
anomalies.forEach(a => {
|
||
const alertEl = document.createElement('div');
|
||
alertEl.style.cssText = 'padding: 5px 15px; background: rgba(255,51,102,0.1); border-left: 2px solid var(--accent-red); font-size: 10px; color: var(--accent-red);';
|
||
alertEl.textContent = '⚠ ' + a.message;
|
||
row.appendChild(alertEl);
|
||
});
|
||
}
|
||
|
||
// Limit displayed devices
|
||
while (content.children.length > 50) {
|
||
content.removeChild(content.lastChild);
|
||
}
|
||
}
|
||
|
||
// Toggle recon panel visibility
|
||
function toggleRecon() {
|
||
reconEnabled = !reconEnabled;
|
||
localStorage.setItem('reconEnabled', reconEnabled);
|
||
document.getElementById('reconPanel').style.display = reconEnabled ? 'block' : 'none';
|
||
document.getElementById('reconBtn').classList.toggle('active', reconEnabled);
|
||
|
||
// Populate recon display if enabled and we have data
|
||
if (reconEnabled && deviceDatabase.size > 0) {
|
||
deviceDatabase.forEach((profile, deviceId) => {
|
||
updateReconDisplay(deviceId, profile, false, []);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Initialize recon state
|
||
if (reconEnabled) {
|
||
document.getElementById('reconPanel').style.display = 'block';
|
||
document.getElementById('reconBtn').classList.add('active');
|
||
} else {
|
||
document.getElementById('reconPanel').style.display = 'none';
|
||
}
|
||
|
||
// Hook into existing message handlers to track devices
|
||
const originalAddMessage = addMessage;
|
||
addMessage = function (msg) {
|
||
originalAddMessage(msg);
|
||
trackDevice(msg);
|
||
};
|
||
|
||
const originalAddSensorReading = addSensorReading;
|
||
addSensorReading = function (data) {
|
||
originalAddSensorReading(data);
|
||
trackDevice(data);
|
||
};
|
||
|
||
// Export device database
|
||
function exportDeviceDB() {
|
||
const data = [];
|
||
deviceDatabase.forEach((profile, id) => {
|
||
data.push({
|
||
id: id,
|
||
protocol: profile.protocol,
|
||
firstSeen: new Date(profile.firstSeen).toISOString(),
|
||
lastSeen: new Date(profile.lastSeen).toISOString(),
|
||
transmissionCount: profile.transmissionCount,
|
||
avgIntervalSeconds: profile.avgInterval ? Math.round(profile.avgInterval / 1000) : null,
|
||
addresses: Array.from(profile.addresses),
|
||
models: Array.from(profile.models),
|
||
encrypted: profile.encrypted,
|
||
anomalyCount: profile.anomalies.length,
|
||
recentMessages: profile.messages.slice(0, 5).map(m => m.text)
|
||
});
|
||
});
|
||
downloadFile(JSON.stringify(data, null, 2), 'intercept_device_intelligence.json', 'application/json');
|
||
}
|
||
|
||
// Toggle recon panel collapse
|
||
function toggleReconCollapse() {
|
||
const panel = document.getElementById('reconPanel');
|
||
const icon = document.getElementById('reconCollapseIcon');
|
||
panel.classList.toggle('collapsed');
|
||
icon.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
|
||
}
|
||
|
||
function toggleSensorWaterfall() {
|
||
const panel = document.getElementById('sensorWaterfallPanel');
|
||
const icon = document.getElementById('sensorWaterfallIcon');
|
||
panel.classList.toggle('collapsed');
|
||
icon.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
|
||
}
|
||
|
||
function togglePagerWaterfall() {
|
||
const panel = document.getElementById('pagerWaterfallPanel');
|
||
const icon = document.getElementById('pagerWaterfallIcon');
|
||
panel.classList.toggle('collapsed');
|
||
icon.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
|
||
}
|
||
|
||
// ============== WIFI RECONNAISSANCE ==============
|
||
|
||
let wifiEventSource = null;
|
||
let monitorInterface = null;
|
||
let wifiNetworks = {};
|
||
let wifiClients = {};
|
||
let apCount = 0;
|
||
let clientCount = 0;
|
||
let handshakeCount = 0;
|
||
let rogueApCount = 0;
|
||
let droneCount = 0;
|
||
let detectedDrones = {}; // Track detected drones by BSSID
|
||
let ssidToBssids = {}; // Track SSIDs to their BSSIDs for rogue AP detection
|
||
let rogueApDetails = {}; // Store details about rogue APs: {ssid: [{bssid, signal, channel, firstSeen}]}
|
||
let rogueBssids = new Set(); // Track all BSSIDs that are suspected rogues
|
||
let activeCapture = null; // {bssid, channel, file, startTime, pollInterval}
|
||
let watchMacs = JSON.parse(localStorage.getItem('watchMacs') || '[]');
|
||
let alertedMacs = new Set(); // Prevent duplicate alerts per session
|
||
let selectedWifiDevice = null; // Selected network or client for details view
|
||
let selectedWifiType = null; // 'network' or 'client'
|
||
|
||
// 5GHz channel mapping for the graph
|
||
const channels5g = ['36', '40', '44', '48', '52', '56', '60', '64', '100', '149', '153', '157', '161', '165'];
|
||
|
||
// Drone SSID patterns for detection
|
||
const dronePatterns = [
|
||
/^DJI[-_]/i, /Mavic/i, /Phantom/i, /^Spark[-_]/i, /^Mini[-_]/i, /^Air[-_]/i,
|
||
/Inspire/i, /Matrice/i, /Avata/i, /^FPV[-_]/i, /Osmo/i, /RoboMaster/i, /Tello/i,
|
||
/Parrot/i, /Bebop/i, /Anafi/i, /^Disco[-_]/i, /Mambo/i, /Swing/i,
|
||
/Autel/i, /^EVO[-_]/i, /Dragonfish/i, /Skydio/i,
|
||
/Holy.?Stone/i, /Potensic/i, /SYMA/i, /Hubsan/i, /Eachine/i, /FIMI/i,
|
||
/Yuneec/i, /Typhoon/i, /PowerVision/i, /PowerEgg/i,
|
||
/Drone/i, /^UAV[-_]/i, /Quadcopter/i, /^RC[-_]Drone/i
|
||
];
|
||
|
||
// Drone OUI prefixes
|
||
const droneOuiPrefixes = {
|
||
'60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI',
|
||
'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI',
|
||
'90:03:B7': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot',
|
||
'8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel', 'F8:0F:6F': 'Skydio'
|
||
};
|
||
|
||
// Check if network is a drone
|
||
function isDrone(ssid, bssid) {
|
||
// Check SSID patterns
|
||
if (ssid) {
|
||
for (const pattern of dronePatterns) {
|
||
if (pattern.test(ssid)) {
|
||
return { isDrone: true, method: 'SSID', brand: ssid.split(/[-_\s]/)[0] };
|
||
}
|
||
}
|
||
}
|
||
// Check OUI prefix
|
||
if (bssid) {
|
||
const prefix = bssid.substring(0, 8).toUpperCase();
|
||
if (droneOuiPrefixes[prefix]) {
|
||
return { isDrone: true, method: 'OUI', brand: droneOuiPrefixes[prefix] };
|
||
}
|
||
}
|
||
return { isDrone: false };
|
||
}
|
||
|
||
// Handle drone detection
|
||
function handleDroneDetection(net, droneInfo) {
|
||
if (detectedDrones[net.bssid]) return; // Already detected
|
||
|
||
detectedDrones[net.bssid] = {
|
||
ssid: net.essid,
|
||
bssid: net.bssid,
|
||
brand: droneInfo.brand,
|
||
method: droneInfo.method,
|
||
signal: net.power,
|
||
channel: net.channel,
|
||
firstSeen: new Date().toISOString()
|
||
};
|
||
|
||
droneCount++;
|
||
document.getElementById('droneCount').textContent = droneCount;
|
||
|
||
// Calculate approximate distance from signal strength
|
||
const rssi = parseInt(net.power) || -70;
|
||
const distance = estimateDroneDistance(rssi);
|
||
|
||
// Triple alert for drones
|
||
playAlert();
|
||
setTimeout(playAlert, 200);
|
||
setTimeout(playAlert, 400);
|
||
|
||
// Show drone alert
|
||
showDroneAlert(net.essid, net.bssid, droneInfo.brand, distance, rssi);
|
||
}
|
||
|
||
// Estimate distance from RSSI (rough approximation)
|
||
function estimateDroneDistance(rssi) {
|
||
// Using free-space path loss model (very approximate)
|
||
// Reference: -30 dBm at 1 meter
|
||
const txPower = -30;
|
||
const n = 2.5; // Path loss exponent (2-4, higher for obstacles)
|
||
const distance = Math.pow(10, (txPower - rssi) / (10 * n));
|
||
return Math.round(distance);
|
||
}
|
||
|
||
// Show drone alert popup
|
||
function showDroneAlert(ssid, bssid, brand, distance, rssi) {
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'drone-alert';
|
||
alertDiv.innerHTML = `
|
||
<div style="font-weight: bold; color: var(--accent-orange); font-size: 16px;">🚁 DRONE DETECTED</div>
|
||
<div style="margin: 10px 0;">
|
||
<div><strong>SSID:</strong> ${escapeHtml(ssid || 'Unknown')}</div>
|
||
<div><strong>BSSID:</strong> ${bssid}</div>
|
||
<div><strong>Brand:</strong> ${brand || 'Unknown'}</div>
|
||
<div><strong>Signal:</strong> ${rssi} dBm</div>
|
||
<div><strong>Est. Distance:</strong> ~${distance}m</div>
|
||
</div>
|
||
<button onclick="this.parentElement.remove()" style="padding: 6px 16px; cursor: pointer; background: var(--accent-orange); border: none; color: #000; border-radius: 4px;">Dismiss</button>
|
||
`;
|
||
alertDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1a1a2e; border: 2px solid var(--accent-orange); padding: 20px; border-radius: 8px; z-index: 10000; text-align: center; box-shadow: 0 0 30px rgba(255,165,0,0.5); min-width: 280px;';
|
||
document.body.appendChild(alertDiv);
|
||
setTimeout(() => { if (alertDiv.parentElement) alertDiv.remove(); }, 15000);
|
||
}
|
||
|
||
// Initialize watch list display
|
||
function initWatchList() {
|
||
updateWatchListDisplay();
|
||
}
|
||
|
||
// Add MAC to watch list
|
||
function addWatchMac() {
|
||
const input = document.getElementById('watchMacInput');
|
||
const mac = input.value.trim().toUpperCase();
|
||
if (!mac || !/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(mac)) {
|
||
alert('Please enter a valid MAC address (AA:BB:CC:DD:EE:FF)');
|
||
return;
|
||
}
|
||
if (!watchMacs.includes(mac)) {
|
||
watchMacs.push(mac);
|
||
localStorage.setItem('watchMacs', JSON.stringify(watchMacs));
|
||
updateWatchListDisplay();
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
// Remove MAC from watch list
|
||
function removeWatchMac(mac) {
|
||
watchMacs = watchMacs.filter(m => m !== mac);
|
||
localStorage.setItem('watchMacs', JSON.stringify(watchMacs));
|
||
alertedMacs.delete(mac);
|
||
updateWatchListDisplay();
|
||
}
|
||
|
||
// Update watch list display
|
||
function updateWatchListDisplay() {
|
||
const container = document.getElementById('watchList');
|
||
if (!container) return;
|
||
if (watchMacs.length === 0) {
|
||
container.innerHTML = '<div style="color: #555;">No MACs in watch list</div>';
|
||
} else {
|
||
container.innerHTML = watchMacs.map(mac =>
|
||
`<div style="display: flex; justify-content: space-between; align-items: center; padding: 2px 0;">
|
||
<span>${mac}</span>
|
||
<button onclick="removeWatchMac('${mac}')" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 10px;">✕</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
// Check if MAC is in watch list and alert
|
||
function checkWatchList(mac, type) {
|
||
const upperMac = mac.toUpperCase();
|
||
if (watchMacs.includes(upperMac) && !alertedMacs.has(upperMac)) {
|
||
alertedMacs.add(upperMac);
|
||
// Play alert sound multiple times for urgency
|
||
playAlert();
|
||
setTimeout(playAlert, 300);
|
||
setTimeout(playAlert, 600);
|
||
// Show prominent alert
|
||
showProximityAlert(mac, type);
|
||
}
|
||
}
|
||
|
||
// Show proximity alert popup
|
||
function showProximityAlert(mac, type) {
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'proximity-alert';
|
||
alertDiv.innerHTML = `
|
||
<div style="font-weight: bold; color: var(--accent-red);">⚠ PROXIMITY ALERT</div>
|
||
<div>Watched ${type} detected:</div>
|
||
<div style="font-family: monospace; font-size: 14px;">${mac}</div>
|
||
<button onclick="this.parentElement.remove()" style="margin-top: 8px; padding: 4px 12px; cursor: pointer;">Dismiss</button>
|
||
`;
|
||
alertDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1a1a2e; border: 2px solid var(--accent-red); padding: 20px; border-radius: 8px; z-index: 10000; text-align: center; box-shadow: 0 0 30px rgba(255,0,0,0.5);';
|
||
document.body.appendChild(alertDiv);
|
||
// Auto-dismiss after 10 seconds
|
||
setTimeout(() => alertDiv.remove(), 10000);
|
||
}
|
||
|
||
// Check for rogue APs (same SSID, different BSSID)
|
||
// Extract OUI (manufacturer ID) from MAC address
|
||
function getOui(mac) {
|
||
if (!mac) return '';
|
||
return mac.toUpperCase().substring(0, 8); // First 3 octets: "AA:BB:CC"
|
||
}
|
||
|
||
function checkRogueAP(ssid, bssid, channel, signal) {
|
||
if (!ssid || ssid === 'Hidden' || ssid === '[Hidden]') return false;
|
||
|
||
if (!ssidToBssids[ssid]) {
|
||
ssidToBssids[ssid] = new Set();
|
||
}
|
||
|
||
// Store details for this BSSID
|
||
if (!rogueApDetails[ssid]) {
|
||
rogueApDetails[ssid] = [];
|
||
}
|
||
|
||
// Check if we already have this BSSID stored
|
||
const existingEntry = rogueApDetails[ssid].find(e => e.bssid === bssid);
|
||
if (!existingEntry) {
|
||
rogueApDetails[ssid].push({
|
||
bssid: bssid,
|
||
channel: channel || '?',
|
||
signal: signal || '?',
|
||
oui: getOui(bssid),
|
||
firstSeen: new Date().toLocaleTimeString()
|
||
});
|
||
}
|
||
|
||
const isNewBssid = !ssidToBssids[ssid].has(bssid);
|
||
ssidToBssids[ssid].add(bssid);
|
||
|
||
// Only flag as rogue if multiple BSSIDs AND different manufacturers (OUIs)
|
||
// This prevents false positives from mesh WiFi systems and enterprise networks
|
||
if (ssidToBssids[ssid].size > 1 && isNewBssid) {
|
||
// Check if all BSSIDs have the same OUI (manufacturer)
|
||
const ouis = new Set(rogueApDetails[ssid].map(e => e.oui));
|
||
|
||
// If all BSSIDs have the same OUI, it's likely a mesh system - not rogue
|
||
if (ouis.size === 1) {
|
||
// Same manufacturer - probably mesh system, not rogue
|
||
return false;
|
||
}
|
||
|
||
// Different manufacturers detected - this is suspicious!
|
||
rogueApCount++;
|
||
document.getElementById('rogueApCount').textContent = rogueApCount;
|
||
playAlert();
|
||
|
||
// Mark ALL BSSIDs with this SSID as suspected rogues
|
||
ssidToBssids[ssid].forEach(b => rogueBssids.add(b));
|
||
|
||
// Get the BSSIDs to show in alert
|
||
const bssidList = rogueApDetails[ssid].map(e => e.bssid).join(', ');
|
||
showInfo(`⚠ Rogue AP: "${ssid}" has ${ouis.size} different vendors: ${bssidList}`);
|
||
showNotification('⚠️ Rogue AP Detected!', `"${ssid}" has different vendor BSSIDs`);
|
||
|
||
// Update all network cards with this SSID to show rogue indicator
|
||
ssidToBssids[ssid].forEach(rogueBssid => {
|
||
const net = wifiNetworks[rogueBssid];
|
||
if (net) addWifiNetworkCard(net, false);
|
||
});
|
||
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Show rogue AP details popup
|
||
function showRogueApDetails() {
|
||
const rogueSSIDs = Object.keys(rogueApDetails).filter(ssid =>
|
||
rogueApDetails[ssid].length > 1
|
||
);
|
||
|
||
if (rogueSSIDs.length === 0) {
|
||
showInfo('No rogue APs detected. Rogue AP = same SSID on multiple BSSIDs.');
|
||
return;
|
||
}
|
||
|
||
// Remove existing popup if any
|
||
const existing = document.getElementById('rogueApPopup');
|
||
if (existing) existing.remove();
|
||
|
||
// Build details HTML
|
||
let html = '<div style="max-height: 300px; overflow-y: auto;">';
|
||
rogueSSIDs.forEach(ssid => {
|
||
const aps = rogueApDetails[ssid];
|
||
html += `<div style="margin-bottom: 12px;">
|
||
<div style="color: var(--accent-red); font-weight: bold; margin-bottom: 4px;">
|
||
📡 "${ssid}" (${aps.length} BSSIDs)
|
||
</div>
|
||
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||
<tr style="color: var(--text-dim);">
|
||
<th style="text-align: left; padding: 2px 8px;">BSSID</th>
|
||
<th style="text-align: left; padding: 2px 8px;">CH</th>
|
||
<th style="text-align: left; padding: 2px 8px;">Signal</th>
|
||
<th style="text-align: left; padding: 2px 8px;">First Seen</th>
|
||
</tr>`;
|
||
aps.forEach((ap, idx) => {
|
||
const bgColor = idx % 2 === 0 ? 'rgba(255,255,255,0.05)' : 'transparent';
|
||
html += `<tr style="background: ${bgColor};">
|
||
<td style="padding: 2px 8px; font-family: monospace;">${ap.bssid}</td>
|
||
<td style="padding: 2px 8px;">${ap.channel}</td>
|
||
<td style="padding: 2px 8px;">${ap.signal} dBm</td>
|
||
<td style="padding: 2px 8px;">${ap.firstSeen}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</table></div>';
|
||
});
|
||
html += '</div>';
|
||
html += '<div style="margin-top: 8px; font-size: 9px; color: var(--text-dim);">⚠ Multiple BSSIDs for same SSID may indicate rogue AP or legitimate multi-AP setup</div>';
|
||
|
||
// Create popup
|
||
const popup = document.createElement('div');
|
||
popup.id = 'rogueApPopup';
|
||
popup.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--accent-red);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
z-index: 10000;
|
||
min-width: 400px;
|
||
max-width: 600px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||
`;
|
||
popup.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<span style="font-weight: bold; color: var(--accent-red);">🚨 Rogue AP Details</span>
|
||
<button onclick="this.parentElement.parentElement.remove()"
|
||
style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px;">✕</button>
|
||
</div>
|
||
${html}
|
||
`;
|
||
|
||
document.body.appendChild(popup);
|
||
}
|
||
|
||
// Show drone details popup
|
||
function showDroneDetails() {
|
||
const drones = Object.values(detectedDrones);
|
||
|
||
if (drones.length === 0) {
|
||
showInfo('No drones detected. Drones are identified by SSID patterns and manufacturer OUI.');
|
||
return;
|
||
}
|
||
|
||
// Remove existing popup if any
|
||
const existing = document.getElementById('droneDetailsPopup');
|
||
if (existing) existing.remove();
|
||
|
||
// Build details HTML
|
||
let html = '<div style="max-height: 300px; overflow-y: auto;">';
|
||
html += `<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||
<tr style="color: var(--text-dim);">
|
||
<th style="text-align: left; padding: 4px 8px;">Brand</th>
|
||
<th style="text-align: left; padding: 4px 8px;">SSID</th>
|
||
<th style="text-align: left; padding: 4px 8px;">BSSID</th>
|
||
<th style="text-align: left; padding: 4px 8px;">CH</th>
|
||
<th style="text-align: left; padding: 4px 8px;">Signal</th>
|
||
<th style="text-align: left; padding: 4px 8px;">Distance</th>
|
||
<th style="text-align: left; padding: 4px 8px;">Detected</th>
|
||
</tr>`;
|
||
|
||
drones.forEach((drone, idx) => {
|
||
const bgColor = idx % 2 === 0 ? 'rgba(255,165,0,0.1)' : 'transparent';
|
||
const rssi = parseInt(drone.signal) || -70;
|
||
const distance = estimateDroneDistance(rssi);
|
||
const timeStr = new Date(drone.firstSeen).toLocaleTimeString();
|
||
html += `<tr style="background: ${bgColor};">
|
||
<td style="padding: 4px 8px; font-weight: bold; color: var(--accent-orange);">${drone.brand || 'Unknown'}</td>
|
||
<td style="padding: 4px 8px;">${drone.ssid || '[Hidden]'}</td>
|
||
<td style="padding: 4px 8px; font-family: monospace; font-size: 9px;">${drone.bssid}</td>
|
||
<td style="padding: 4px 8px;">${drone.channel || '?'}</td>
|
||
<td style="padding: 4px 8px;">${drone.signal || '?'} dBm</td>
|
||
<td style="padding: 4px 8px;">~${distance}m</td>
|
||
<td style="padding: 4px 8px;">${timeStr}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</table></div>';
|
||
html += '<div style="margin-top: 8px; font-size: 9px; color: var(--text-dim);">Detection via: SSID pattern matching and manufacturer OUI lookup</div>';
|
||
|
||
// Create popup
|
||
const popup = document.createElement('div');
|
||
popup.id = 'droneDetailsPopup';
|
||
popup.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--accent-orange);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
z-index: 10000;
|
||
min-width: 500px;
|
||
max-width: 700px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||
`;
|
||
popup.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<span style="font-weight: bold; color: var(--accent-orange);">🚁 Detected Drones (${drones.length})</span>
|
||
<button onclick="this.parentElement.parentElement.remove()"
|
||
style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px;">✕</button>
|
||
</div>
|
||
${html}
|
||
`;
|
||
|
||
document.body.appendChild(popup);
|
||
}
|
||
|
||
// Update 5GHz channel graph
|
||
function updateChannel5gGraph() {
|
||
const bars = document.querySelectorAll('#channelGraph5g .channel-bar');
|
||
const labels = document.querySelectorAll('#channelGraph5g .channel-label');
|
||
|
||
// Count networks per 5GHz channel
|
||
const channelCounts = {};
|
||
channels5g.forEach(ch => channelCounts[ch] = 0);
|
||
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const ch = net.channel?.toString().trim();
|
||
if (channels5g.includes(ch)) {
|
||
channelCounts[ch]++;
|
||
}
|
||
});
|
||
|
||
const maxCount = Math.max(1, ...Object.values(channelCounts));
|
||
|
||
bars.forEach((bar, i) => {
|
||
const ch = channels5g[i];
|
||
const count = channelCounts[ch] || 0;
|
||
const height = Math.max(2, (count / maxCount) * 50);
|
||
bar.style.height = height + 'px';
|
||
bar.className = 'channel-bar' + (count > 0 ? ' active' : '') + (count > 3 ? ' congested' : '') + (count > 5 ? ' very-congested' : '');
|
||
});
|
||
}
|
||
|
||
// ============== NEW FEATURES ==============
|
||
|
||
// Network Topology Graph
|
||
function drawNetworkGraph() {
|
||
const canvas = document.getElementById('networkGraph');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.offsetWidth;
|
||
const height = canvas.offsetHeight;
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
// Clear
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
const networks = Object.values(wifiNetworks);
|
||
const clients = Object.values(wifiClients);
|
||
|
||
if (networks.length === 0) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '12px sans-serif';
|
||
ctx.fillText('Start scanning to see network topology', width / 2 - 100, height / 2);
|
||
return;
|
||
}
|
||
|
||
// Calculate positions for APs (top row)
|
||
const apPositions = {};
|
||
const apSpacing = width / (networks.length + 1);
|
||
networks.forEach((net, i) => {
|
||
apPositions[net.bssid] = {
|
||
x: apSpacing * (i + 1),
|
||
y: 40,
|
||
ssid: net.essid,
|
||
isDrone: isDrone(net.essid, net.bssid).isDrone
|
||
};
|
||
});
|
||
|
||
// Draw connections from clients to APs
|
||
ctx.strokeStyle = '#1a1a1a';
|
||
ctx.lineWidth = 1;
|
||
clients.forEach(client => {
|
||
if (client.ap && apPositions[client.ap]) {
|
||
const ap = apPositions[client.ap];
|
||
const clientY = 120 + (Math.random() * 60);
|
||
const clientX = ap.x + (Math.random() - 0.5) * 80;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(ap.x, ap.y + 15);
|
||
ctx.lineTo(clientX, clientY - 10);
|
||
ctx.stroke();
|
||
|
||
// Draw client node
|
||
ctx.beginPath();
|
||
ctx.arc(clientX, clientY, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#00ff88';
|
||
ctx.fill();
|
||
}
|
||
});
|
||
|
||
// Draw AP nodes
|
||
Object.entries(apPositions).forEach(([bssid, pos]) => {
|
||
ctx.beginPath();
|
||
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
|
||
ctx.fillStyle = pos.isDrone ? '#ff8800' : '#00d4ff';
|
||
ctx.fill();
|
||
|
||
// Draw label
|
||
ctx.fillStyle = '#888';
|
||
ctx.font = '9px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
const label = (pos.ssid || 'Hidden').substring(0, 12);
|
||
ctx.fillText(label, pos.x, pos.y + 25);
|
||
});
|
||
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// Channel Recommendation
|
||
function updateChannelRecommendation() {
|
||
const channelCounts24 = {};
|
||
const channelCounts5 = {};
|
||
|
||
// Initialize
|
||
for (let i = 1; i <= 13; i++) channelCounts24[i] = 0;
|
||
channels5g.forEach(ch => channelCounts5[ch] = 0);
|
||
|
||
// Count networks per channel
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const ch = parseInt(net.channel);
|
||
if (ch >= 1 && ch <= 13) {
|
||
// 2.4 GHz channels overlap, so count neighbors too
|
||
for (let i = Math.max(1, ch - 2); i <= Math.min(13, ch + 2); i++) {
|
||
channelCounts24[i] = (channelCounts24[i] || 0) + (i === ch ? 1 : 0.5);
|
||
}
|
||
} else if (channels5g.includes(ch.toString())) {
|
||
channelCounts5[ch.toString()]++;
|
||
}
|
||
});
|
||
|
||
// Count total networks for context
|
||
const totalNetworks = Object.keys(wifiNetworks).length;
|
||
|
||
// Find best 2.4 GHz channel (1, 6, or 11 preferred - non-overlapping)
|
||
const preferred24 = [1, 6, 11];
|
||
let best24 = 1;
|
||
let minCount24 = Infinity;
|
||
let channelUsage24 = [];
|
||
preferred24.forEach(ch => {
|
||
channelUsage24.push({ channel: ch, count: channelCounts24[ch] || 0 });
|
||
if ((channelCounts24[ch] || 0) < minCount24) {
|
||
minCount24 = channelCounts24[ch] || 0;
|
||
best24 = ch;
|
||
}
|
||
});
|
||
|
||
// Find best 5 GHz channel
|
||
let best5 = '36';
|
||
let minCount5 = Infinity;
|
||
let used5g = 0;
|
||
channels5g.forEach(ch => {
|
||
const count = channelCounts5[ch] || 0;
|
||
if (count > 0) used5g++;
|
||
if (count < minCount5) {
|
||
minCount5 = count;
|
||
best5 = ch;
|
||
}
|
||
});
|
||
|
||
// Update UI with more context
|
||
document.getElementById('rec24Channel').textContent = best24;
|
||
if (totalNetworks === 0) {
|
||
document.getElementById('rec24Reason').textContent = '(no networks detected)';
|
||
} else {
|
||
const usage = channelUsage24.map(c => `CH${c.channel}:${Math.round(c.count)}`).join(', ');
|
||
document.getElementById('rec24Reason').textContent =
|
||
minCount24 === 0 ? '(clear)' : `(${Math.round(minCount24)} interference) [${usage}]`;
|
||
}
|
||
|
||
document.getElementById('rec5Channel').textContent = best5;
|
||
if (totalNetworks === 0) {
|
||
document.getElementById('rec5Reason').textContent = '(no networks detected)';
|
||
} else {
|
||
document.getElementById('rec5Reason').textContent =
|
||
minCount5 === 0 ? `(clear, ${channels5g.length - used5g} unused)` : `(${minCount5} networks)`;
|
||
}
|
||
}
|
||
|
||
// Device Correlation (WiFi <-> Bluetooth)
|
||
let deviceCorrelations = [];
|
||
let correlationFetchPending = false;
|
||
|
||
function correlateDevices() {
|
||
// Use server-side correlation API for better analysis
|
||
if (correlationFetchPending) return;
|
||
correlationFetchPending = true;
|
||
|
||
fetch('/correlation?min_confidence=0.4')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
deviceCorrelations = data.correlations || [];
|
||
updateCorrelationDisplay();
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.warn('Correlation fetch failed:', err);
|
||
// Fallback to local OUI matching
|
||
correlateDevicesLocal();
|
||
})
|
||
.finally(() => {
|
||
correlationFetchPending = false;
|
||
});
|
||
}
|
||
|
||
function correlateDevicesLocal() {
|
||
// Fallback: simple OUI-based correlation
|
||
deviceCorrelations = [];
|
||
const wifiMacs = Object.keys(wifiNetworks).concat(Object.keys(wifiClients));
|
||
const btMacs = Object.keys(btDevices || {});
|
||
|
||
wifiMacs.forEach(wifiMac => {
|
||
const wifiOui = wifiMac.substring(0, 8).toUpperCase();
|
||
btMacs.forEach(btMac => {
|
||
const btOui = btMac.substring(0, 8).toUpperCase();
|
||
if (wifiOui === btOui) {
|
||
const wifiDev = wifiNetworks[wifiMac] || wifiClients[wifiMac];
|
||
const btDev = btDevices[btMac];
|
||
deviceCorrelations.push({
|
||
wifi_mac: wifiMac,
|
||
bt_mac: btMac,
|
||
wifi_name: wifiDev?.essid || wifiDev?.mac || wifiMac,
|
||
bt_name: btDev?.name || btMac,
|
||
confidence: 0.5,
|
||
reason: 'same OUI'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
updateCorrelationDisplay();
|
||
}
|
||
|
||
function updateCorrelationDisplay() {
|
||
const list = document.getElementById('correlationList');
|
||
if (!list) return;
|
||
|
||
if (deviceCorrelations.length === 0) {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">No correlated devices found yet</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = deviceCorrelations.slice(0, 10).map(c => {
|
||
const confidence = Math.round((c.confidence || 0.5) * 100);
|
||
const confidenceColor = confidence >= 70 ? 'var(--accent-green)' :
|
||
confidence >= 50 ? 'var(--accent-orange)' : 'var(--text-dim)';
|
||
return `
|
||
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="color: var(--accent-cyan);">📶 ${c.wifi_name || c.wifi_mac}</span>
|
||
<span class="correlation-badge" style="background: ${confidenceColor};">${confidence}%</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="color: #6495ED;">🔵 ${c.bt_name || c.bt_mac}</span>
|
||
<span style="font-size: 9px; color: var(--text-dim);">${c.reason || ''}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Hidden SSID Revealer
|
||
let revealedSsids = {}; // {bssid: ssid}
|
||
|
||
function revealHiddenSsid(bssid, ssid) {
|
||
if (ssid && ssid !== '' && ssid !== 'Hidden' && ssid !== '[Hidden]') {
|
||
if (!revealedSsids[bssid]) {
|
||
revealedSsids[bssid] = ssid;
|
||
updateHiddenSsidDisplay();
|
||
showNotification('Hidden SSID Revealed', `"${ssid}" on ${bssid}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateHiddenSsidDisplay() {
|
||
const list = document.getElementById('hiddenSsidList');
|
||
if (!list) return;
|
||
|
||
const entries = Object.entries(revealedSsids);
|
||
const hiddenCount = Object.keys(hiddenNetworks).length;
|
||
|
||
if (entries.length === 0) {
|
||
if (hiddenCount > 0) {
|
||
list.innerHTML = `<div style="color: var(--text-dim);">Monitoring ${hiddenCount} hidden network${hiddenCount > 1 ? 's' : ''}...</div>`;
|
||
} else {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">No hidden networks detected</div>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
let html = entries.map(([bssid, ssid]) => `
|
||
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
|
||
<span style="color: var(--accent-green);">✓ "${escapeHtml(ssid)}"</span>
|
||
<span style="color: var(--text-dim); font-size: 9px;"> (${bssid})</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
if (hiddenCount > 0) {
|
||
html += `<div style="color: var(--text-dim); margin-top: 4px; font-size: 10px;">+ ${hiddenCount} hidden still monitoring</div>`;
|
||
}
|
||
|
||
list.innerHTML = html;
|
||
}
|
||
|
||
// NOTE: Browser Notifications code moved to static/js/core/audio.js
|
||
|
||
// Update visualizations periodically
|
||
setInterval(() => {
|
||
if (currentMode === 'wifi') {
|
||
updateChannelRecommendation();
|
||
correlateDevices();
|
||
updateHiddenSsidDisplay();
|
||
updateProbeAnalysis();
|
||
}
|
||
}, 2000);
|
||
|
||
// Refresh WiFi interfaces
|
||
function refreshWifiInterfaces() {
|
||
const select = document.getElementById('wifiInterfaceSelect');
|
||
select.innerHTML = '<option value="">Loading interfaces...</option>';
|
||
|
||
fetch('/wifi/interfaces')
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Failed to fetch interfaces');
|
||
return r.json();
|
||
})
|
||
.then(data => {
|
||
if (!data.interfaces || data.interfaces.length === 0) {
|
||
select.innerHTML = '<option value="">No WiFi interfaces found</option>';
|
||
showNotification('WiFi', 'No WiFi interfaces detected. Make sure you have a WiFi adapter connected.');
|
||
} else {
|
||
select.innerHTML = data.interfaces.map(i => {
|
||
// Build descriptive label with available info
|
||
let label = i.name;
|
||
let details = [];
|
||
if (i.chipset) details.push(i.chipset);
|
||
else if (i.driver) details.push(i.driver);
|
||
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
|
||
if (details.length > 0) label += ' - ' + details.join(' | ');
|
||
label += ` (${i.type})`;
|
||
if (i.monitor_capable) label += ' [Monitor OK]';
|
||
return `<option value="${i.name}">${label}</option>`;
|
||
}).join('');
|
||
showNotification('WiFi', `Found ${data.interfaces.length} interface(s)`);
|
||
}
|
||
|
||
// Update tool status
|
||
const statusDiv = document.getElementById('wifiToolStatus');
|
||
if (statusDiv) {
|
||
statusDiv.innerHTML = `
|
||
<span>airmon-ng:</span><span class="tool-status ${data.tools?.airmon ? 'ok' : 'missing'}">${data.tools?.airmon ? 'OK' : 'Missing'}</span>
|
||
<span>airodump-ng:</span><span class="tool-status ${data.tools?.airodump ? 'ok' : 'missing'}">${data.tools?.airodump ? 'OK' : 'Missing'}</span>
|
||
`;
|
||
}
|
||
|
||
// Update monitor status
|
||
if (data.monitor_interface) {
|
||
monitorInterface = data.monitor_interface;
|
||
updateMonitorStatus(true);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Error fetching WiFi interfaces:', err);
|
||
select.innerHTML = '<option value="">Error loading interfaces</option>';
|
||
showNotification('WiFi Error', 'Could not detect WiFi interfaces: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// Enable monitor mode
|
||
function enableMonitorMode() {
|
||
const iface = document.getElementById('wifiInterfaceSelect').value;
|
||
if (!iface) {
|
||
alert('Please select an interface');
|
||
return;
|
||
}
|
||
|
||
const killProcesses = document.getElementById('killProcesses').checked;
|
||
|
||
// Show loading state
|
||
const btn = document.getElementById('monitorStartBtn');
|
||
const originalText = btn.textContent;
|
||
btn.textContent = 'Enabling...';
|
||
btn.disabled = true;
|
||
|
||
fetch('/wifi/monitor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
|
||
if (data.status === 'success') {
|
||
monitorInterface = data.monitor_interface;
|
||
updateMonitorStatus(true);
|
||
showInfo('Monitor mode enabled on ' + monitorInterface + ' - Ready to scan!');
|
||
|
||
// Refresh interface list and auto-select the monitor interface
|
||
fetch('/wifi/interfaces')
|
||
.then(r => r.json())
|
||
.then(ifaceData => {
|
||
const select = document.getElementById('wifiInterfaceSelect');
|
||
if (ifaceData.interfaces.length > 0) {
|
||
select.innerHTML = ifaceData.interfaces.map(i => {
|
||
let label = i.name;
|
||
let details = [];
|
||
if (i.chipset) details.push(i.chipset);
|
||
else if (i.driver) details.push(i.driver);
|
||
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
|
||
if (details.length > 0) label += ' - ' + details.join(' | ');
|
||
label += ` (${i.type})`;
|
||
if (i.monitor_capable) label += ' [Monitor OK]';
|
||
return `<option value="${i.name}" ${i.name === monitorInterface ? 'selected' : ''}>${label}</option>`;
|
||
}).join('');
|
||
}
|
||
});
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
alert('Error: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// Disable monitor mode
|
||
function disableMonitorMode() {
|
||
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
|
||
|
||
fetch('/wifi/monitor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, action: 'stop' })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
monitorInterface = null;
|
||
updateMonitorStatus(false);
|
||
showInfo('Monitor mode disabled');
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateMonitorStatus(enabled) {
|
||
document.getElementById('monitorStartBtn').style.display = enabled ? 'none' : 'block';
|
||
document.getElementById('monitorStopBtn').style.display = enabled ? 'block' : 'none';
|
||
document.getElementById('monitorStatus').innerHTML = enabled
|
||
? 'Monitor mode: <span style="color: var(--accent-green);">Active (' + monitorInterface + ')</span>'
|
||
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
|
||
}
|
||
|
||
// Start WiFi scan - auto-enables monitor mode if needed
|
||
async function startWifiScan() {
|
||
console.log('startWifiScan called');
|
||
const band = document.getElementById('wifiBand').value;
|
||
const channel = document.getElementById('wifiChannel').value;
|
||
|
||
// Auto-enable monitor mode if not already enabled
|
||
if (!monitorInterface) {
|
||
const iface = document.getElementById('wifiInterfaceSelect').value;
|
||
console.log('Selected interface:', iface);
|
||
|
||
if (!iface) {
|
||
showNotification('WiFi Error', 'No WiFi interface selected. Please select an adapter from the dropdown.');
|
||
alert('No WiFi interface selected. Please select an adapter from the dropdown above.');
|
||
return;
|
||
}
|
||
|
||
// Show status
|
||
document.getElementById('statusText').textContent = 'Enabling monitor mode...';
|
||
document.getElementById('statusDot').classList.add('running');
|
||
showNotification('WiFi', 'Enabling monitor mode on ' + iface + '...');
|
||
|
||
try {
|
||
const killProcesses = document.getElementById('killProcesses').checked;
|
||
console.log('Enabling monitor mode, kill processes:', killProcesses);
|
||
|
||
const monitorResp = await fetch('/wifi/monitor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses })
|
||
});
|
||
const monitorData = await monitorResp.json();
|
||
console.log('Monitor response:', monitorData);
|
||
|
||
if (monitorData.status === 'success') {
|
||
monitorInterface = monitorData.monitor_interface;
|
||
updateMonitorStatus(true);
|
||
showNotification('Monitor Mode', 'Enabled on ' + monitorInterface);
|
||
} else {
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Monitor Error', monitorData.message || 'Failed to enable monitor mode');
|
||
alert('Monitor mode failed: ' + (monitorData.message || 'Unknown error'));
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
console.error('Monitor mode error:', err);
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Monitor Error', err.message);
|
||
alert('Monitor mode error: ' + err.message);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Now start the scan
|
||
document.getElementById('statusText').textContent = 'Starting scan...';
|
||
console.log('Starting scan on', monitorInterface);
|
||
|
||
try {
|
||
const scanResp = await fetch('/wifi/scan/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
interface: monitorInterface,
|
||
band: band,
|
||
channel: channel || null
|
||
})
|
||
});
|
||
const scanData = await scanResp.json();
|
||
console.log('Scan response:', scanData);
|
||
|
||
if (scanData.status === 'started') {
|
||
setWifiRunning(true);
|
||
startWifiStream();
|
||
showNotification('WiFi Scanner', 'Scanning started on ' + monitorInterface);
|
||
} else {
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Scan Error', scanData.message || 'Failed to start scan');
|
||
alert('Scan failed: ' + (scanData.message || 'Unknown error'));
|
||
}
|
||
} catch (err) {
|
||
console.error('Scan error:', err);
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Scan Error', err.message);
|
||
alert('Scan error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// Stop WiFi scan
|
||
function stopWifiScan() {
|
||
fetch('/wifi/scan/stop', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setWifiRunning(false);
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
wifiEventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function setWifiRunning(running) {
|
||
isWifiRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Scanning...' : 'Idle';
|
||
document.getElementById('startWifiBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopWifiBtn').style.display = running ? 'block' : 'none';
|
||
}
|
||
|
||
// Batching state for WiFi updates
|
||
let pendingWifiUpdate = false;
|
||
let pendingWifiNetworks = [];
|
||
let pendingWifiClients = [];
|
||
|
||
function scheduleWifiUIUpdate() {
|
||
if (pendingWifiUpdate) return;
|
||
pendingWifiUpdate = true;
|
||
requestAnimationFrame(() => {
|
||
// Process networks
|
||
pendingWifiNetworks.forEach(data => handleWifiNetworkImmediate(data));
|
||
pendingWifiNetworks = [];
|
||
|
||
// Process clients (limit to last 5 per frame)
|
||
const clientsToProcess = pendingWifiClients.slice(-5);
|
||
pendingWifiClients = [];
|
||
clientsToProcess.forEach(data => handleWifiClientImmediate(data));
|
||
|
||
// Update graphs once per frame instead of per-network
|
||
updateChannelGraph();
|
||
updateChannel5gGraph();
|
||
|
||
// Update selected device panel
|
||
updateWifiSelectedDevice();
|
||
|
||
// Update probe analysis (throttled)
|
||
if (clientsToProcess.length > 0) {
|
||
scheduleProbeAnalysisUpdate();
|
||
}
|
||
|
||
pendingWifiUpdate = false;
|
||
});
|
||
}
|
||
|
||
// Start WiFi event stream
|
||
function startWifiStream() {
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
}
|
||
|
||
wifiEventSource = new EventSource('/wifi/stream');
|
||
|
||
wifiEventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (data.type === 'network') {
|
||
pendingWifiNetworks.push(data);
|
||
scheduleWifiUIUpdate();
|
||
} else if (data.type === 'client') {
|
||
pendingWifiClients.push(data);
|
||
scheduleWifiUIUpdate();
|
||
} else if (data.type === 'info' || data.type === 'raw') {
|
||
showInfo(data.text);
|
||
} else if (data.type === 'error') {
|
||
showError(data.text);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setWifiRunning(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
wifiEventSource.onerror = function () {
|
||
console.error('WiFi stream error');
|
||
};
|
||
}
|
||
|
||
// Track networks that were originally hidden
|
||
let hiddenNetworks = {}; // {bssid: true} for networks first seen with hidden ESSID
|
||
|
||
// Handle discovered WiFi network (called from batched update)
|
||
function handleWifiNetworkImmediate(net) {
|
||
const isNew = !wifiNetworks[net.bssid];
|
||
const previousNet = wifiNetworks[net.bssid];
|
||
wifiNetworks[net.bssid] = net;
|
||
|
||
// Track if this network was originally hidden
|
||
if (isNew) {
|
||
const isHidden = !net.essid || net.essid === '' || net.essid === 'Hidden' || net.essid === '[Hidden]';
|
||
if (isHidden) {
|
||
hiddenNetworks[net.bssid] = true;
|
||
}
|
||
}
|
||
|
||
// Check if a previously hidden network now has a revealed SSID
|
||
if (hiddenNetworks[net.bssid] && net.essid && net.essid !== '' && net.essid !== 'Hidden' && net.essid !== '[Hidden]') {
|
||
revealHiddenSsid(net.bssid, net.essid);
|
||
delete hiddenNetworks[net.bssid]; // No longer hidden
|
||
}
|
||
|
||
if (isNew) {
|
||
apCount++;
|
||
document.getElementById('apCount').textContent = apCount;
|
||
playAlert();
|
||
pulseSignal();
|
||
|
||
// Check for rogue AP (same SSID, different BSSID)
|
||
checkRogueAP(net.essid, net.bssid, net.channel, net.power);
|
||
|
||
// Check proximity watch list
|
||
checkWatchList(net.bssid, 'AP');
|
||
|
||
// Check for drone
|
||
const droneCheck = isDrone(net.essid, net.bssid);
|
||
if (droneCheck.isDrone) {
|
||
handleDroneDetection(net, droneCheck);
|
||
showNotification('🚁 Drone Detected!', `${droneCheck.brand}: ${net.essid}`);
|
||
}
|
||
}
|
||
|
||
// Update recon display
|
||
const droneInfo = isDrone(net.essid, net.bssid);
|
||
trackDevice({
|
||
protocol: droneInfo.isDrone ? 'DRONE' : 'WiFi-AP',
|
||
address: net.bssid,
|
||
message: net.essid || '[Hidden SSID]',
|
||
model: net.essid,
|
||
channel: net.channel,
|
||
privacy: net.privacy,
|
||
isDrone: droneInfo.isDrone,
|
||
droneBrand: droneInfo.brand
|
||
});
|
||
|
||
// Add to output
|
||
addWifiNetworkCard(net, isNew);
|
||
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
|
||
}
|
||
|
||
// Handle discovered WiFi client (called from batched update)
|
||
function handleWifiClientImmediate(client) {
|
||
const isNew = !wifiClients[client.mac];
|
||
wifiClients[client.mac] = client;
|
||
|
||
if (isNew) {
|
||
clientCount++;
|
||
document.getElementById('clientCount').textContent = clientCount;
|
||
|
||
// Check proximity watch list
|
||
checkWatchList(client.mac, 'Client');
|
||
}
|
||
|
||
// If client is connected to a hidden network and has probes, try to reveal the SSID
|
||
if (client.bssid && hiddenNetworks[client.bssid] && client.probes) {
|
||
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
|
||
if (probes.length > 0) {
|
||
// Use the first probe as the likely SSID for this hidden network
|
||
revealHiddenSsid(client.bssid, probes[0]);
|
||
delete hiddenNetworks[client.bssid];
|
||
}
|
||
}
|
||
|
||
// Track in device intelligence with vendor info
|
||
const vendorInfo = client.vendor && client.vendor !== 'Unknown' ? ` [${client.vendor}]` : '';
|
||
trackDevice({
|
||
protocol: 'WiFi-Client',
|
||
address: client.mac,
|
||
message: (client.probes || '[No probes]') + vendorInfo,
|
||
bssid: client.bssid,
|
||
vendor: client.vendor
|
||
});
|
||
|
||
// Update probe analysis when we get client data with probes
|
||
if (client.probes && client.probes.trim()) {
|
||
scheduleProbeAnalysisUpdate();
|
||
}
|
||
|
||
// Add client card to device list
|
||
addWifiClientCard(client, isNew);
|
||
}
|
||
|
||
// Throttled probe analysis (called less frequently)
|
||
let lastProbeAnalysisUpdate = 0;
|
||
function scheduleProbeAnalysisUpdate() {
|
||
const now = Date.now();
|
||
if (now - lastProbeAnalysisUpdate > 2000) {
|
||
lastProbeAnalysisUpdate = now;
|
||
updateProbeAnalysis();
|
||
}
|
||
}
|
||
|
||
// Update client probe analysis panel
|
||
function updateProbeAnalysis() {
|
||
const list = document.getElementById('probeAnalysisList');
|
||
if (!list) return;
|
||
|
||
const clientsWithProbes = Object.values(wifiClients).filter(c => c.probes && c.probes.trim());
|
||
const allProbes = new Set();
|
||
let privacyLeaks = 0;
|
||
|
||
// Count unique probes and privacy leaks
|
||
clientsWithProbes.forEach(client => {
|
||
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
|
||
probes.forEach(p => allProbes.add(p));
|
||
|
||
// Check for sensitive network names (home networks, corporate, etc.)
|
||
probes.forEach(probe => {
|
||
const lowerProbe = probe.toLowerCase();
|
||
if (lowerProbe.includes('home') || lowerProbe.includes('office') ||
|
||
lowerProbe.includes('corp') || lowerProbe.includes('work') ||
|
||
lowerProbe.includes('private') || lowerProbe.includes('hotel') ||
|
||
lowerProbe.includes('airport') || lowerProbe.match(/^[a-z]+-[a-z]+$/i)) {
|
||
privacyLeaks++;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Update counters
|
||
document.getElementById('probeClientCount').textContent = clientsWithProbes.length;
|
||
document.getElementById('probeSSIDCount').textContent = allProbes.size;
|
||
document.getElementById('probePrivacyCount').textContent = privacyLeaks;
|
||
|
||
if (clientsWithProbes.length === 0) {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">Waiting for client probe requests...</div>';
|
||
return;
|
||
}
|
||
|
||
// Sort by number of probes (most revealing first)
|
||
clientsWithProbes.sort((a, b) => {
|
||
const aCount = (a.probes || '').split(',').length;
|
||
const bCount = (b.probes || '').split(',').length;
|
||
return bCount - aCount;
|
||
});
|
||
|
||
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||
|
||
clientsWithProbes.forEach(client => {
|
||
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
|
||
const vendorBadge = client.vendor && client.vendor !== 'Unknown'
|
||
? `<span style="background: var(--bg-tertiary); padding: 1px 4px; border-radius: 2px; font-size: 9px; margin-left: 5px;">${escapeHtml(client.vendor)}</span>`
|
||
: '';
|
||
|
||
// Check for privacy-revealing probes
|
||
const probeHtml = probes.map(probe => {
|
||
const lowerProbe = probe.toLowerCase();
|
||
const isSensitive = lowerProbe.includes('home') || lowerProbe.includes('office') ||
|
||
lowerProbe.includes('corp') || lowerProbe.includes('work') ||
|
||
lowerProbe.includes('private') || lowerProbe.includes('hotel') ||
|
||
lowerProbe.includes('airport') || lowerProbe.match(/^[a-z]+-[a-z]+$/i);
|
||
|
||
const style = isSensitive
|
||
? 'background: var(--accent-orange); color: #000; padding: 1px 4px; border-radius: 2px; margin: 1px;'
|
||
: 'background: var(--bg-tertiary); padding: 1px 4px; border-radius: 2px; margin: 1px;';
|
||
|
||
return `<span style="${style}" title="${isSensitive ? 'Potentially sensitive - reveals user location history' : ''}">${escapeHtml(probe)}</span>`;
|
||
}).join(' ');
|
||
|
||
html += `
|
||
<div style="border-left: 2px solid var(--accent-cyan); padding-left: 8px; cursor: pointer;" onclick="selectWifiDevice('${escapeAttr(client.mac)}', 'client')" title="Click for details">
|
||
<div style="display: flex; align-items: center; gap: 5px; margin-bottom: 3px;">
|
||
<span style="color: var(--accent-cyan); font-family: monospace; font-size: 10px;">${escapeHtml(client.mac)}</span>
|
||
${vendorBadge}
|
||
<span style="color: var(--text-dim); font-size: 9px;">(${probes.length} probe${probes.length !== 1 ? 's' : ''})</span>
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 2px; font-size: 10px;">
|
||
${probeHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div>';
|
||
list.innerHTML = html;
|
||
}
|
||
|
||
// Select a WiFi network or client for detailed view
|
||
function selectWifiDevice(id, type) {
|
||
selectedWifiDevice = id;
|
||
selectedWifiType = type;
|
||
updateWifiSelectedDevice();
|
||
}
|
||
|
||
// Update the selected WiFi device panel
|
||
function updateWifiSelectedDevice() {
|
||
const panel = document.getElementById('wifiSelectedDevice');
|
||
if (!panel) return;
|
||
|
||
if (!selectedWifiDevice) {
|
||
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a network or client to view details</div>';
|
||
return;
|
||
}
|
||
|
||
if (selectedWifiType === 'network') {
|
||
const net = wifiNetworks[selectedWifiDevice];
|
||
if (!net) {
|
||
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Network no longer visible</div>';
|
||
return;
|
||
}
|
||
|
||
const power = parseInt(net.power) || -100;
|
||
const signalPercent = Math.max(0, Math.min(100, (power + 100) * 2));
|
||
const signalColor = power >= -50 ? 'var(--accent-green)' : power >= -70 ? 'var(--accent-orange)' : 'var(--accent-red)';
|
||
const isRogue = rogueBssids.has(net.bssid);
|
||
|
||
panel.innerHTML = `
|
||
${isRogue ? '<div class="rogue-indicator" style="margin: -10px -10px 10px -10px; padding: 8px;">⚠️ SUSPECTED ROGUE ACCESS POINT</div>' : ''}
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||
<div style="grid-column: span 2; text-align: center; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);">
|
||
<div style="font-size: 18px; color: ${isRogue ? 'var(--accent-red)' : 'var(--accent-cyan)'}; font-weight: bold;">${escapeHtml(net.essid || '[Hidden]')}</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${escapeHtml(net.bssid)}</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">SIGNAL</div>
|
||
<div style="color: ${signalColor}; font-size: 16px; font-weight: bold;">${power} dBm</div>
|
||
<div style="background: var(--bg-tertiary); height: 4px; border-radius: 2px; margin-top: 4px;">
|
||
<div style="background: ${signalColor}; height: 100%; width: ${signalPercent}%; border-radius: 2px;"></div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">CHANNEL</div>
|
||
<div style="color: var(--accent-cyan); font-size: 16px; font-weight: bold;">${net.channel}</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">SECURITY</div>
|
||
<div style="color: ${(net.privacy || '').includes('WPA3') ? 'var(--accent-green)' : (net.privacy || '').includes('WPA') ? 'var(--accent-orange)' : 'var(--accent-red)'};">${escapeHtml(net.privacy || 'Unknown')}</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">BEACONS</div>
|
||
<div style="color: var(--text-secondary);">${net.beacons || 0}</div>
|
||
</div>
|
||
<div style="grid-column: span 2; display: flex; gap: 8px; margin-top: 8px;">
|
||
<button class="preset-btn" onclick="targetNetwork('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="flex: 1;">Target</button>
|
||
<button class="preset-btn" onclick="captureHandshake('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="flex: 1; border-color: var(--accent-orange); color: var(--accent-orange);">Handshake</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (selectedWifiType === 'client') {
|
||
const client = wifiClients[selectedWifiDevice];
|
||
if (!client) {
|
||
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Client no longer visible</div>';
|
||
return;
|
||
}
|
||
|
||
const power = parseInt(client.power) || -100;
|
||
const signalPercent = Math.max(0, Math.min(100, (power + 100) * 2));
|
||
const signalColor = power >= -50 ? 'var(--accent-green)' : power >= -70 ? 'var(--accent-orange)' : 'var(--accent-red)';
|
||
const probes = (client.probes || '').split(',').map(p => p.trim()).filter(p => p);
|
||
const associatedNet = client.bssid && wifiNetworks[client.bssid];
|
||
|
||
panel.innerHTML = `
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||
<div style="grid-column: span 2; text-align: center; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);">
|
||
<div style="font-size: 14px; color: var(--accent-orange); font-weight: bold;">CLIENT DEVICE</div>
|
||
<div style="font-size: 12px; color: var(--text-secondary);">${escapeHtml(client.mac)}</div>
|
||
${client.vendor ? `<div style="font-size: 10px; color: var(--text-muted);">${escapeHtml(client.vendor)}</div>` : ''}
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">SIGNAL</div>
|
||
<div style="color: ${signalColor}; font-size: 16px; font-weight: bold;">${power} dBm</div>
|
||
<div style="background: var(--bg-tertiary); height: 4px; border-radius: 2px; margin-top: 4px;">
|
||
<div style="background: ${signalColor}; height: 100%; width: ${signalPercent}%; border-radius: 2px;"></div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">PACKETS</div>
|
||
<div style="color: var(--text-secondary);">${client.packets || 0}</div>
|
||
</div>
|
||
${associatedNet ? `
|
||
<div style="grid-column: span 2; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">CONNECTED TO</div>
|
||
<div style="color: var(--accent-cyan);">${escapeHtml(associatedNet.essid || associatedNet.bssid)}</div>
|
||
</div>
|
||
` : ''}
|
||
${probes.length > 0 ? `
|
||
<div style="grid-column: span 2; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">PROBING FOR</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
|
||
${probes.slice(0, 5).map(p => `<span style="background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px;">${escapeHtml(p)}</span>`).join('')}
|
||
${probes.length > 5 ? `<span style="color: var(--text-muted);">+${probes.length - 5} more</span>` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Add WiFi network card to device list
|
||
function addWifiNetworkCard(net, isNew) {
|
||
// Use the WiFi device list panel instead of the generic output
|
||
const deviceList = document.getElementById('wifiDeviceListContent');
|
||
if (!deviceList) return;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder && placeholder.textContent.includes('Start scanning')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Check if card already exists
|
||
let card = document.getElementById('wifi_' + net.bssid.replace(/:/g, ''));
|
||
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = 'wifi_' + net.bssid.replace(/:/g, '');
|
||
card.className = 'sensor-card wifi-network-card';
|
||
card.style.borderLeftColor = net.privacy.includes('WPA') ? 'var(--accent-orange)' :
|
||
net.privacy.includes('WEP') ? 'var(--accent-red)' :
|
||
'var(--accent-green)';
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = () => selectWifiDevice(net.bssid, 'network');
|
||
deviceList.insertBefore(card, deviceList.firstChild);
|
||
|
||
// Update device count
|
||
const countEl = document.getElementById('wifiDeviceListCount');
|
||
if (countEl) countEl.textContent = Object.keys(wifiNetworks).length;
|
||
}
|
||
|
||
// Handle signal strength - airodump returns -1 when not measured
|
||
let signalStrength = parseInt(net.power);
|
||
if (isNaN(signalStrength) || signalStrength === -1) {
|
||
signalStrength = null; // No reading available
|
||
}
|
||
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
|
||
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
|
||
|
||
const wpsEnabled = net.wps === '1' || net.wps === 'Yes' || (net.privacy || '').includes('WPS');
|
||
const wpsHtml = wpsEnabled ? '<span class="wps-enabled">WPS</span>' : '';
|
||
const isRogue = rogueBssids.has(net.bssid);
|
||
const rogueHtml = isRogue ? '<div class="rogue-indicator">⚠️ SUSPECTED ROGUE AP</div>' : '';
|
||
|
||
// Update card border for rogue APs
|
||
if (isRogue) {
|
||
card.style.borderLeftColor = 'var(--accent-red)';
|
||
card.style.borderLeftWidth = '4px';
|
||
card.style.background = 'rgba(255, 0, 0, 0.1)';
|
||
}
|
||
|
||
card.innerHTML = `
|
||
${rogueHtml}
|
||
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||
<span class="device-name">${escapeHtml(net.essid || '[Hidden]')}${wpsHtml}</span>
|
||
<span style="color: #444; font-size: 10px;">CH ${net.channel}</span>
|
||
</div>
|
||
<div class="sensor-data">
|
||
<div class="data-item">
|
||
<div class="data-label">BSSID</div>
|
||
<div class="data-value" style="font-size: 11px;">${escapeHtml(net.bssid)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Security</div>
|
||
<div class="data-value" style="color: ${(net.privacy || '').includes('WPA') ? 'var(--accent-orange)' : net.privacy === 'OPN' ? 'var(--accent-green)' : 'var(--accent-red)'}">${escapeHtml(net.privacy || '')}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Signal</div>
|
||
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Beacons</div>
|
||
<div class="data-value">${net.beacons}</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 5px; flex-wrap: wrap;">
|
||
<button class="preset-btn" onclick="targetNetwork('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px;">Target</button>
|
||
<button class="preset-btn" onclick="captureHandshake('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px; border-color: var(--accent-orange); color: var(--accent-orange);">Handshake</button>
|
||
</div>
|
||
`;
|
||
|
||
if (autoScroll) output.scrollTop = 0;
|
||
}
|
||
|
||
// Add WiFi client card to device list
|
||
function addWifiClientCard(client, isNew) {
|
||
const deviceList = document.getElementById('wifiDeviceListContent');
|
||
if (!deviceList) return;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder && placeholder.textContent.includes('Start scanning')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Check if card already exists
|
||
let card = document.getElementById('client_' + client.mac.replace(/:/g, ''));
|
||
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = 'client_' + client.mac.replace(/:/g, '');
|
||
card.className = 'sensor-card wifi-client-card';
|
||
card.style.borderLeftColor = 'var(--accent-purple)';
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = () => selectWifiDevice(client.mac, 'client');
|
||
deviceList.appendChild(card); // Clients go after networks
|
||
|
||
// Update device count
|
||
const countEl = document.getElementById('wifiDeviceListCount');
|
||
if (countEl) countEl.textContent = Object.keys(wifiNetworks).length + Object.keys(wifiClients).length;
|
||
}
|
||
|
||
// Handle signal strength
|
||
let signalStrength = parseInt(client.power);
|
||
if (isNaN(signalStrength) || signalStrength === -1) {
|
||
signalStrength = null;
|
||
}
|
||
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
|
||
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
|
||
|
||
// Get connected AP info
|
||
const connectedAP = client.bssid && wifiNetworks[client.bssid];
|
||
const apName = connectedAP ? (connectedAP.essid || '[Hidden]') : (client.bssid || 'Not Associated');
|
||
|
||
// Format probes
|
||
const probes = client.probes ? client.probes.split(',').map(p => p.trim()).filter(p => p) : [];
|
||
const probesDisplay = probes.length > 0 ? probes.slice(0, 3).join(', ') + (probes.length > 3 ? ` +${probes.length - 3}` : '') : 'None';
|
||
|
||
card.innerHTML = `
|
||
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||
<span class="device-name" style="color: var(--accent-purple);">📱 ${escapeHtml(client.vendor || 'Client')}</span>
|
||
<span style="font-size: 10px; color: var(--text-dim);">CLIENT</span>
|
||
</div>
|
||
<div class="sensor-data">
|
||
<div class="data-item">
|
||
<div class="data-label">MAC</div>
|
||
<div class="data-value" style="font-size: 11px;">${escapeHtml(client.mac)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Connected To</div>
|
||
<div class="data-value" style="color: var(--accent-cyan);">${escapeHtml(apName)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Signal</div>
|
||
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Probes</div>
|
||
<div class="data-value" style="font-size: 10px;">${escapeHtml(probesDisplay)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Target a network for attack
|
||
function targetNetwork(bssid, channel) {
|
||
document.getElementById('targetBssid').value = bssid;
|
||
document.getElementById('wifiChannel').value = channel;
|
||
showInfo('Targeted: ' + bssid + ' on channel ' + channel);
|
||
}
|
||
|
||
// Start handshake capture
|
||
async function captureHandshake(bssid, channel) {
|
||
if (!confirm('Start handshake capture for ' + bssid + '? This will stop the current scan.')) {
|
||
return;
|
||
}
|
||
|
||
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
|
||
if (!iface) {
|
||
showError('No monitor interface available. Enable monitor mode first.');
|
||
return;
|
||
}
|
||
|
||
// Stop any existing scan first
|
||
if (isWifiRunning) {
|
||
showInfo('Stopping current scan...');
|
||
try {
|
||
await fetch('/wifi/scan/stop', { method: 'POST' });
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
wifiEventSource = null;
|
||
}
|
||
setWifiRunning(false);
|
||
// Brief delay to ensure process stops
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
} catch (e) {
|
||
console.error('Error stopping scan:', e);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/wifi/handshake/capture', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bssid: bssid, channel: channel, interface: iface })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'started') {
|
||
showInfo('🎯 Capturing handshakes for ' + bssid);
|
||
setWifiRunning(true);
|
||
|
||
// Update handshake indicator to show active capture
|
||
const hsSpan = document.getElementById('handshakeCount');
|
||
hsSpan.style.animation = 'pulse 1s infinite';
|
||
hsSpan.title = 'Capturing: ' + bssid;
|
||
|
||
// Show capture status panel
|
||
const panel = document.getElementById('captureStatusPanel');
|
||
panel.style.display = 'block';
|
||
document.getElementById('captureTargetBssid').textContent = bssid;
|
||
document.getElementById('captureTargetChannel').textContent = channel;
|
||
document.getElementById('captureFilePath').textContent = data.capture_file;
|
||
document.getElementById('captureStatus').textContent = 'Waiting for handshake...';
|
||
document.getElementById('captureStatus').style.color = 'var(--accent-orange)';
|
||
|
||
// Store active capture info and start polling
|
||
activeCapture = {
|
||
bssid: bssid,
|
||
channel: channel,
|
||
file: data.capture_file,
|
||
startTime: Date.now(),
|
||
pollInterval: setInterval(checkCaptureStatus, 5000) // Check every 5 seconds
|
||
};
|
||
} else {
|
||
showError('Handshake capture failed: ' + (data.message || 'Unknown error'));
|
||
}
|
||
} catch (err) {
|
||
showError('Handshake capture error: ' + err.message);
|
||
console.error('Handshake capture error:', err);
|
||
}
|
||
}
|
||
|
||
// Check handshake capture status
|
||
function checkCaptureStatus() {
|
||
if (!activeCapture) {
|
||
showInfo('No active handshake capture');
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/handshake/status', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ file: activeCapture.file, bssid: activeCapture.bssid })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
const statusSpan = document.getElementById('captureStatus');
|
||
const elapsed = Math.round((Date.now() - activeCapture.startTime) / 1000);
|
||
const elapsedStr = elapsed < 60 ? elapsed + 's' : Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's';
|
||
|
||
if (data.handshake_found) {
|
||
// Handshake captured!
|
||
statusSpan.textContent = '✓ HANDSHAKE CAPTURED!';
|
||
statusSpan.style.color = 'var(--accent-green)';
|
||
handshakeCount++;
|
||
document.getElementById('handshakeCount').textContent = handshakeCount;
|
||
playAlert();
|
||
showInfo('🎉 Handshake captured for ' + activeCapture.bssid + '! File: ' + data.file);
|
||
showNotification('🤝 Handshake Captured!', `Target: ${activeCapture.bssid}`);
|
||
|
||
// Stop polling
|
||
if (activeCapture.pollInterval) {
|
||
clearInterval(activeCapture.pollInterval);
|
||
}
|
||
document.getElementById('handshakeCount').style.animation = '';
|
||
|
||
// Show crack button in the capture panel
|
||
const panel = document.getElementById('captureStatusPanel');
|
||
const existingCrackBtn = panel.querySelector('.crack-btn');
|
||
if (!existingCrackBtn) {
|
||
const crackDiv = document.createElement('div');
|
||
crackDiv.style.marginTop = '10px';
|
||
crackDiv.innerHTML = `
|
||
<button class="preset-btn crack-btn" onclick="crackHandshake('${data.file}', '${activeCapture.bssid}')" style="width: 100%; background: var(--accent-green); border-color: var(--accent-green); color: #000; font-weight: bold;">
|
||
🔓 Crack with Aircrack-ng
|
||
</button>
|
||
`;
|
||
panel.querySelector('.section') ? panel.querySelector('.section').appendChild(crackDiv) : panel.appendChild(crackDiv);
|
||
}
|
||
|
||
// Store the captured file for later use
|
||
activeCapture.captured = true;
|
||
activeCapture.capturedFile = data.file;
|
||
} else if (data.file_exists) {
|
||
const sizeKB = (data.file_size / 1024).toFixed(1);
|
||
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')';
|
||
statusSpan.style.color = 'var(--accent-orange)';
|
||
} else if (data.status === 'stopped') {
|
||
statusSpan.textContent = 'Capture stopped';
|
||
statusSpan.style.color = 'var(--text-dim)';
|
||
if (activeCapture.pollInterval) {
|
||
clearInterval(activeCapture.pollInterval);
|
||
}
|
||
} else {
|
||
statusSpan.textContent = 'Waiting for data... (' + elapsedStr + ')';
|
||
statusSpan.style.color = 'var(--accent-orange)';
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Capture status check failed:', err);
|
||
});
|
||
}
|
||
|
||
// Stop handshake capture
|
||
function stopHandshakeCapture() {
|
||
if (activeCapture && activeCapture.pollInterval) {
|
||
clearInterval(activeCapture.pollInterval);
|
||
}
|
||
|
||
// Stop the WiFi scan (which stops airodump-ng)
|
||
stopWifiScan();
|
||
|
||
document.getElementById('captureStatus').textContent = 'Stopped';
|
||
document.getElementById('captureStatus').style.color = 'var(--text-dim)';
|
||
document.getElementById('handshakeCount').style.animation = '';
|
||
|
||
// Keep the panel visible so user can see the file path
|
||
showInfo('Handshake capture stopped. Check ' + (activeCapture ? activeCapture.file : 'capture file'));
|
||
|
||
activeCapture = null;
|
||
}
|
||
|
||
// Crack handshake with aircrack-ng
|
||
function crackHandshake(captureFile, bssid) {
|
||
const wordlist = prompt('Enter path to wordlist file:\n\nCommon locations:\n- /usr/share/wordlists/rockyou.txt\n- /usr/share/john/password.lst', '/usr/share/wordlists/rockyou.txt');
|
||
|
||
if (!wordlist) {
|
||
showInfo('Cracking cancelled');
|
||
return;
|
||
}
|
||
|
||
showInfo('Starting aircrack-ng... This may take a while.');
|
||
|
||
fetch('/wifi/handshake/crack', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
capture_file: captureFile,
|
||
bssid: bssid,
|
||
wordlist: wordlist
|
||
})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.password) {
|
||
showInfo('🎉 PASSWORD FOUND: ' + data.password);
|
||
showNotification('🔓 Password Cracked!', data.password);
|
||
alert('Password found!\n\n' + data.password + '\n\nThis has been logged.');
|
||
} else if (data.status === 'not_found') {
|
||
showInfo('Password not found in wordlist. Try a different wordlist.');
|
||
alert('Password not found in wordlist.\n\nTry using a larger or different wordlist.');
|
||
} else if (data.status === 'running') {
|
||
showInfo('Aircrack-ng is running in background. Check terminal for progress.');
|
||
} else {
|
||
showError('Crack failed: ' + (data.message || 'Unknown error'));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showError('Crack error: ' + err.message);
|
||
console.error('Crack error:', err);
|
||
});
|
||
}
|
||
|
||
// Beacon Flood Detection
|
||
let beaconHistory = [];
|
||
let lastBeaconCheck = Date.now();
|
||
|
||
function checkBeaconFlood(networks) {
|
||
const now = Date.now();
|
||
const windowMs = 5000; // 5 second window
|
||
|
||
// Add current networks to history
|
||
beaconHistory.push({ time: now, count: Object.keys(networks).length });
|
||
|
||
// Remove old entries
|
||
beaconHistory = beaconHistory.filter(h => now - h.time < windowMs);
|
||
|
||
// Calculate rate of new networks
|
||
if (beaconHistory.length >= 2) {
|
||
const oldest = beaconHistory[0];
|
||
const newest = beaconHistory[beaconHistory.length - 1];
|
||
const timeDiff = (newest.time - oldest.time) / 1000;
|
||
const countDiff = newest.count - oldest.count;
|
||
|
||
if (timeDiff > 0) {
|
||
const rate = countDiff / timeDiff;
|
||
|
||
// Alert if more than 10 new networks per second
|
||
if (rate > 10) {
|
||
document.getElementById('beaconFloodAlert').style.display = 'block';
|
||
document.getElementById('beaconFloodRate').textContent = rate.toFixed(1);
|
||
if (!muted) playAlertSound();
|
||
} else if (rate < 2) {
|
||
document.getElementById('beaconFloodAlert').style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send deauth
|
||
function sendDeauth() {
|
||
const bssid = document.getElementById('targetBssid').value;
|
||
const client = document.getElementById('targetClient').value || 'FF:FF:FF:FF:FF:FF';
|
||
const count = document.getElementById('deauthCount').value || '5';
|
||
|
||
if (!bssid) {
|
||
alert('Enter target BSSID');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Send ' + count + ' deauth packets to ' + bssid + '?\\n\\n⚠ Only use on networks you own or have authorization to test!')) {
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/deauth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bssid: bssid, client: client, count: parseInt(count) })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
showInfo(data.message);
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============== WIFI VISUALIZATIONS ==============
|
||
|
||
let radarCtx = null;
|
||
let radarAngle = 0;
|
||
let radarAnimFrame = null;
|
||
let radarNetworks = []; // {x, y, strength, ssid, bssid}
|
||
let targetBssidForSignal = null;
|
||
|
||
// Initialize radar canvas
|
||
function initRadar() {
|
||
const canvas = document.getElementById('radarCanvas');
|
||
if (!canvas) return;
|
||
|
||
radarCtx = canvas.getContext('2d');
|
||
canvas.width = 150;
|
||
canvas.height = 150;
|
||
|
||
// Start animation
|
||
if (!radarAnimFrame) {
|
||
animateRadar();
|
||
}
|
||
}
|
||
|
||
// Animate radar sweep
|
||
function animateRadar() {
|
||
if (!radarCtx) {
|
||
radarAnimFrame = null;
|
||
return;
|
||
}
|
||
|
||
const canvas = radarCtx.canvas;
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 5;
|
||
|
||
// Clear canvas
|
||
radarCtx.fillStyle = 'rgba(0, 10, 10, 0.1)';
|
||
radarCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Draw grid circles
|
||
radarCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
|
||
radarCtx.lineWidth = 1;
|
||
for (let r = radius / 4; r <= radius; r += radius / 4) {
|
||
radarCtx.beginPath();
|
||
radarCtx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
radarCtx.stroke();
|
||
}
|
||
|
||
// Draw crosshairs
|
||
radarCtx.beginPath();
|
||
radarCtx.moveTo(cx, cy - radius);
|
||
radarCtx.lineTo(cx, cy + radius);
|
||
radarCtx.moveTo(cx - radius, cy);
|
||
radarCtx.lineTo(cx + radius, cy);
|
||
radarCtx.stroke();
|
||
|
||
// Draw sweep line
|
||
radarCtx.strokeStyle = 'rgba(0, 255, 136, 0.8)';
|
||
radarCtx.lineWidth = 2;
|
||
radarCtx.beginPath();
|
||
radarCtx.moveTo(cx, cy);
|
||
radarCtx.lineTo(
|
||
cx + Math.cos(radarAngle) * radius,
|
||
cy + Math.sin(radarAngle) * radius
|
||
);
|
||
radarCtx.stroke();
|
||
|
||
// Draw sweep gradient
|
||
const gradient = radarCtx.createConicalGradient ?
|
||
null : // Not supported in all browsers
|
||
radarCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||
|
||
radarCtx.fillStyle = 'rgba(0, 255, 136, 0.05)';
|
||
radarCtx.beginPath();
|
||
radarCtx.moveTo(cx, cy);
|
||
radarCtx.arc(cx, cy, radius, radarAngle - 0.5, radarAngle);
|
||
radarCtx.closePath();
|
||
radarCtx.fill();
|
||
|
||
// Draw network blips
|
||
radarNetworks.forEach(net => {
|
||
const age = Date.now() - net.timestamp;
|
||
const alpha = Math.max(0.1, 1 - age / 10000);
|
||
|
||
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha})`;
|
||
radarCtx.beginPath();
|
||
radarCtx.arc(net.x, net.y, 4 + (1 - alpha) * 3, 0, Math.PI * 2);
|
||
radarCtx.fill();
|
||
|
||
// Glow effect
|
||
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha * 0.3})`;
|
||
radarCtx.beginPath();
|
||
radarCtx.arc(net.x, net.y, 8 + (1 - alpha) * 5, 0, Math.PI * 2);
|
||
radarCtx.fill();
|
||
});
|
||
|
||
// Update angle
|
||
radarAngle += 0.03;
|
||
if (radarAngle > Math.PI * 2) radarAngle = 0;
|
||
|
||
radarAnimFrame = requestAnimationFrame(animateRadar);
|
||
}
|
||
|
||
// Add network to radar
|
||
function addNetworkToRadar(net) {
|
||
const canvas = document.getElementById('radarCanvas');
|
||
if (!canvas) return;
|
||
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 10;
|
||
|
||
// Convert signal strength to distance (stronger = closer)
|
||
const power = parseInt(net.power) || -80;
|
||
const distance = Math.max(0.1, Math.min(1, (power + 100) / 60));
|
||
const r = radius * (1 - distance);
|
||
|
||
// Random angle based on BSSID hash
|
||
let angle = 0;
|
||
for (let i = 0; i < net.bssid.length; i++) {
|
||
angle += net.bssid.charCodeAt(i);
|
||
}
|
||
angle = (angle % 360) * Math.PI / 180;
|
||
|
||
const x = cx + Math.cos(angle) * r;
|
||
const y = cy + Math.sin(angle) * r;
|
||
|
||
// Update or add
|
||
const existing = radarNetworks.find(n => n.bssid === net.bssid);
|
||
if (existing) {
|
||
existing.x = x;
|
||
existing.y = y;
|
||
existing.timestamp = Date.now();
|
||
} else {
|
||
radarNetworks.push({
|
||
x, y,
|
||
bssid: net.bssid,
|
||
ssid: net.essid,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
// Limit to 50 networks
|
||
if (radarNetworks.length > 50) {
|
||
radarNetworks.shift();
|
||
}
|
||
}
|
||
|
||
// Update channel graph
|
||
function updateChannelGraph() {
|
||
const channels = {};
|
||
for (let i = 1; i <= 13; i++) channels[i] = 0;
|
||
|
||
// Count networks per channel
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const ch = parseInt(net.channel);
|
||
if (ch >= 1 && ch <= 13) {
|
||
channels[ch]++;
|
||
}
|
||
});
|
||
|
||
// Find max for scaling
|
||
const maxCount = Math.max(1, ...Object.values(channels));
|
||
|
||
// Update bars
|
||
const bars = document.querySelectorAll('#channelGraph .channel-bar');
|
||
bars.forEach((bar, i) => {
|
||
const ch = i + 1;
|
||
const count = channels[ch] || 0;
|
||
const height = Math.max(2, (count / maxCount) * 55);
|
||
bar.style.height = height + 'px';
|
||
|
||
bar.classList.remove('active', 'congested', 'very-congested');
|
||
if (count > 0) bar.classList.add('active');
|
||
if (count >= 3) bar.classList.add('congested');
|
||
if (count >= 5) bar.classList.add('very-congested');
|
||
});
|
||
}
|
||
|
||
// Update security donut chart
|
||
function updateSecurityDonut() {
|
||
const canvas = document.getElementById('securityCanvas');
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 2;
|
||
const innerRadius = radius * 0.6;
|
||
|
||
// Count security types
|
||
let wpa3 = 0, wpa2 = 0, wep = 0, open = 0;
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const priv = (net.privacy || '').toUpperCase();
|
||
if (priv.includes('WPA3')) wpa3++;
|
||
else if (priv.includes('WPA')) wpa2++;
|
||
else if (priv.includes('WEP')) wep++;
|
||
else if (priv === 'OPN' || priv === '' || priv === 'OPEN') open++;
|
||
else wpa2++; // Default to WPA2
|
||
});
|
||
|
||
const total = wpa3 + wpa2 + wep + open;
|
||
|
||
// Update legend
|
||
document.getElementById('wpa3Count').textContent = wpa3;
|
||
document.getElementById('wpa2Count').textContent = wpa2;
|
||
document.getElementById('wepCount').textContent = wep;
|
||
document.getElementById('openCount').textContent = open;
|
||
|
||
// Clear canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
if (total === 0) {
|
||
// Draw empty circle
|
||
ctx.strokeStyle = '#1a1a1a';
|
||
ctx.lineWidth = radius - innerRadius;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, (radius + innerRadius) / 2, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
return;
|
||
}
|
||
|
||
// Draw segments
|
||
const colors = {
|
||
wpa3: '#00ff88',
|
||
wpa2: '#ff8800',
|
||
wep: '#ff3366',
|
||
open: '#00d4ff'
|
||
};
|
||
|
||
const data = [
|
||
{ value: wpa3, color: colors.wpa3 },
|
||
{ value: wpa2, color: colors.wpa2 },
|
||
{ value: wep, color: colors.wep },
|
||
{ value: open, color: colors.open }
|
||
];
|
||
|
||
let startAngle = -Math.PI / 2;
|
||
|
||
data.forEach(segment => {
|
||
if (segment.value === 0) return;
|
||
|
||
const sliceAngle = (segment.value / total) * Math.PI * 2;
|
||
|
||
ctx.fillStyle = segment.color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
startAngle += sliceAngle;
|
||
});
|
||
|
||
// Draw inner circle (donut hole)
|
||
ctx.fillStyle = '#000';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Draw total in center
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = 'bold 16px JetBrains Mono';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(total, cx, cy);
|
||
}
|
||
|
||
// Update signal strength meter for targeted network
|
||
function updateSignalMeter(net) {
|
||
if (!net) return;
|
||
|
||
targetBssidForSignal = net.bssid;
|
||
|
||
const ssidEl = document.getElementById('targetSsid');
|
||
const valueEl = document.getElementById('signalValue');
|
||
const barsEl = document.querySelectorAll('.signal-bar-large');
|
||
|
||
ssidEl.textContent = net.essid || net.bssid;
|
||
|
||
const power = parseInt(net.power) || -100;
|
||
valueEl.textContent = power + ' dBm';
|
||
|
||
// Determine signal quality
|
||
let quality = 'weak';
|
||
let activeBars = 1;
|
||
|
||
if (power >= -50) { quality = 'strong'; activeBars = 5; }
|
||
else if (power >= -60) { quality = 'strong'; activeBars = 4; }
|
||
else if (power >= -70) { quality = 'medium'; activeBars = 3; }
|
||
else if (power >= -80) { quality = 'medium'; activeBars = 2; }
|
||
else { quality = 'weak'; activeBars = 1; }
|
||
|
||
valueEl.className = 'signal-value ' + quality;
|
||
|
||
barsEl.forEach((bar, i) => {
|
||
bar.className = 'signal-bar-large';
|
||
if (i < activeBars) {
|
||
bar.classList.add('active', quality);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Hook into handleWifiNetworkImmediate to update visualizations
|
||
const originalHandleWifiNetworkImmediate = handleWifiNetworkImmediate;
|
||
handleWifiNetworkImmediate = function (net) {
|
||
originalHandleWifiNetworkImmediate(net);
|
||
|
||
// Update radar
|
||
addNetworkToRadar(net);
|
||
|
||
// Update security donut
|
||
updateSecurityDonut();
|
||
|
||
// Update signal meter if this is the targeted network
|
||
if (targetBssidForSignal === net.bssid) {
|
||
updateSignalMeter(net);
|
||
}
|
||
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
|
||
};
|
||
|
||
// Update targetNetwork to also set signal meter
|
||
const originalTargetNetwork = targetNetwork;
|
||
targetNetwork = function (bssid, channel) {
|
||
originalTargetNetwork(bssid, channel);
|
||
|
||
const net = wifiNetworks[bssid];
|
||
if (net) {
|
||
updateSignalMeter(net);
|
||
}
|
||
};
|
||
|
||
// ============== BLUETOOTH RECONNAISSANCE ==============
|
||
|
||
let btEventSource = null;
|
||
let btDevices = {};
|
||
let btDeviceCount = 0;
|
||
let btBeaconCount = 0;
|
||
let btRadarCtx = null;
|
||
let btRadarAngle = 0;
|
||
let btRadarAnimFrame = null;
|
||
let btRadarDevices = [];
|
||
|
||
// Refresh Bluetooth interfaces
|
||
function refreshBtInterfaces() {
|
||
fetch('/bt/interfaces')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const select = document.getElementById('btInterfaceSelect');
|
||
if (data.interfaces.length === 0) {
|
||
select.innerHTML = '<option value="">No BT interfaces found</option>';
|
||
} else {
|
||
select.innerHTML = data.interfaces.map(i =>
|
||
`<option value="${i.name}">${i.name} (${i.type}) [${i.status}]</option>`
|
||
).join('');
|
||
}
|
||
|
||
// Update tool status
|
||
const statusDiv = document.getElementById('btToolStatus');
|
||
statusDiv.innerHTML = `
|
||
<span>hcitool:</span><span class="tool-status ${data.tools.hcitool ? 'ok' : 'missing'}">${data.tools.hcitool ? 'OK' : 'Missing'}</span>
|
||
<span>bluetoothctl:</span><span class="tool-status ${data.tools.bluetoothctl ? 'ok' : 'missing'}">${data.tools.bluetoothctl ? 'OK' : 'Missing'}</span>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// Start Bluetooth scan
|
||
function startBtScan() {
|
||
const scanMode = document.querySelector('input[name="btScanMode"]:checked').value;
|
||
const iface = document.getElementById('btInterfaceSelect').value;
|
||
const duration = document.getElementById('btScanDuration').value;
|
||
const scanBLE = document.getElementById('btScanBLE').checked;
|
||
const scanClassic = document.getElementById('btScanClassic').checked;
|
||
|
||
fetch('/bt/scan/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
mode: scanMode,
|
||
interface: iface,
|
||
duration: parseInt(duration),
|
||
scan_ble: scanBLE,
|
||
scan_classic: scanClassic
|
||
})
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
setBtRunning(true);
|
||
startBtStream();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Stop Bluetooth scan
|
||
function stopBtScan() {
|
||
fetch('/bt/scan/stop', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setBtRunning(false);
|
||
if (btEventSource) {
|
||
btEventSource.close();
|
||
btEventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function resetBtAdapter() {
|
||
const iface = document.getElementById('btInterfaceSelect')?.value || 'hci0';
|
||
fetch('/bt/reset', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
setBtRunning(false);
|
||
if (btEventSource) {
|
||
btEventSource.close();
|
||
btEventSource = null;
|
||
}
|
||
if (data.status === 'success') {
|
||
showInfo('Bluetooth adapter reset. Status: ' + (data.is_up ? 'UP' : 'DOWN'));
|
||
// Refresh interface list
|
||
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
|
||
} else {
|
||
showError('Reset failed: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setBtRunning(running) {
|
||
isBtRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Scanning...' : 'Idle';
|
||
document.getElementById('startBtBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopBtBtn').style.display = running ? 'block' : 'none';
|
||
}
|
||
|
||
// Batching state for Bluetooth updates
|
||
let pendingBtUpdate = false;
|
||
let pendingBtDevices = [];
|
||
|
||
function scheduleBtUIUpdate() {
|
||
if (pendingBtUpdate) return;
|
||
pendingBtUpdate = true;
|
||
requestAnimationFrame(() => {
|
||
// Process devices (limit to 10 per frame)
|
||
const devicesToProcess = pendingBtDevices.slice(0, 10);
|
||
pendingBtDevices = pendingBtDevices.slice(10);
|
||
|
||
devicesToProcess.forEach(data => handleBtDeviceImmediate(data));
|
||
|
||
// If more pending, schedule another frame
|
||
if (pendingBtDevices.length > 0) {
|
||
pendingBtUpdate = false;
|
||
scheduleBtUIUpdate();
|
||
return;
|
||
}
|
||
|
||
pendingBtUpdate = false;
|
||
});
|
||
}
|
||
|
||
// Start Bluetooth event stream
|
||
function startBtStream() {
|
||
if (btEventSource) btEventSource.close();
|
||
|
||
btEventSource = new EventSource('/bt/stream');
|
||
|
||
btEventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (data.type === 'device') {
|
||
pendingBtDevices.push(data);
|
||
scheduleBtUIUpdate();
|
||
} else if (data.type === 'info' || data.type === 'raw') {
|
||
showInfo(data.text);
|
||
} else if (data.type === 'error') {
|
||
showError(data.text);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setBtRunning(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
btEventSource.onerror = function () {
|
||
console.error('BT stream error');
|
||
};
|
||
}
|
||
|
||
// Tracker following detection
|
||
let trackerHistory = {}; // MAC -> { firstSeen, lastSeen, seenCount, locations: [] }
|
||
const FOLLOWING_THRESHOLD_MINUTES = 30;
|
||
const FOLLOWING_MIN_DETECTIONS = 5;
|
||
|
||
// Find My network detection patterns
|
||
const FINDMY_PATTERNS = {
|
||
// Apple Find My / AirTag
|
||
apple: {
|
||
prefixes: ['4C:00'],
|
||
mfgData: [0x004C], // Apple company ID
|
||
names: ['AirTag', 'Find My']
|
||
},
|
||
// Samsung SmartTag
|
||
samsung: {
|
||
prefixes: ['58:4D', 'A0:75', 'DC:0C', 'E4:5F'],
|
||
mfgData: [0x0075], // Samsung company ID
|
||
names: ['SmartTag', 'Galaxy SmartTag']
|
||
},
|
||
// Tile
|
||
tile: {
|
||
prefixes: ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A', 'D0:03'],
|
||
names: ['Tile', 'Tile Pro', 'Tile Mate', 'Tile Slim']
|
||
},
|
||
// Chipolo
|
||
chipolo: {
|
||
prefixes: ['00:0D'],
|
||
names: ['Chipolo', 'CHIPOLO']
|
||
}
|
||
};
|
||
|
||
function detectFindMyDevice(device) {
|
||
const mac = device.mac.toUpperCase();
|
||
const macPrefix = mac.substring(0, 5);
|
||
const name = (device.name || '').toLowerCase();
|
||
|
||
for (const [network, patterns] of Object.entries(FINDMY_PATTERNS)) {
|
||
// Check MAC prefix
|
||
if (patterns.prefixes && patterns.prefixes.some(p => mac.startsWith(p))) {
|
||
return { network: network, type: 'Find My Network', icon: '📍' };
|
||
}
|
||
// Check name patterns
|
||
if (patterns.names && patterns.names.some(n => name.includes(n.toLowerCase()))) {
|
||
return { network: network, type: 'Find My Network', icon: '📍' };
|
||
}
|
||
}
|
||
|
||
// Check manufacturer data for Apple continuity
|
||
if (device.manufacturer_data) {
|
||
const mfgData = device.manufacturer_data;
|
||
if (mfgData.includes('4c00') || mfgData.includes('004c')) {
|
||
// Check for Find My payload (manufacturer specific data type 0x12)
|
||
if (mfgData.includes('12') || mfgData.length > 40) {
|
||
return { network: 'apple', type: 'Apple Find My', icon: '🍎' };
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function checkTrackerFollowing(device) {
|
||
if (!device.tracker && !detectFindMyDevice(device)) return;
|
||
|
||
const mac = device.mac;
|
||
const now = Date.now();
|
||
|
||
if (!trackerHistory[mac]) {
|
||
trackerHistory[mac] = {
|
||
firstSeen: now,
|
||
lastSeen: now,
|
||
seenCount: 1,
|
||
name: device.name || device.mac
|
||
};
|
||
} else {
|
||
trackerHistory[mac].lastSeen = now;
|
||
trackerHistory[mac].seenCount++;
|
||
}
|
||
|
||
const tracker = trackerHistory[mac];
|
||
const durationMinutes = (now - tracker.firstSeen) / 60000;
|
||
|
||
// Alert if tracker has been following for a while
|
||
if (durationMinutes >= FOLLOWING_THRESHOLD_MINUTES && tracker.seenCount >= FOLLOWING_MIN_DETECTIONS) {
|
||
showTrackerFollowingAlert(mac, tracker);
|
||
}
|
||
}
|
||
|
||
function showTrackerFollowingAlert(mac, tracker) {
|
||
const alertDiv = document.getElementById('trackerFollowingAlert');
|
||
if (!alertDiv) return;
|
||
|
||
const durationMinutes = Math.floor((Date.now() - tracker.firstSeen) / 60000);
|
||
|
||
alertDiv.style.display = 'block';
|
||
alertDiv.innerHTML = `
|
||
<h4>⚠️ POSSIBLE TRACKING DETECTED</h4>
|
||
<div style="font-size: 12px;">
|
||
<div><strong>Device:</strong> ${escapeHtml(tracker.name)}</div>
|
||
<div><strong>MAC:</strong> ${escapeHtml(mac)}</div>
|
||
<div><strong>Duration:</strong> ${durationMinutes} minutes</div>
|
||
<div><strong>Detections:</strong> ${tracker.seenCount}</div>
|
||
<div style="margin-top: 10px; color: #ff6666;">
|
||
This tracker has been detected near you for an extended period.
|
||
If you don't recognize this device, consider your safety.
|
||
</div>
|
||
<button onclick="dismissTrackerAlert('${mac}')" class="preset-btn" style="margin-top: 10px; border-color: #ff4444; color: #ff4444;">
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
if (!muted) {
|
||
// Play warning sound
|
||
for (let i = 0; i < 3; i++) {
|
||
setTimeout(() => playAlertSound(), i * 300);
|
||
}
|
||
}
|
||
|
||
showNotification('⚠️ Tracking Alert', `${tracker.name} detected for ${durationMinutes} min`);
|
||
}
|
||
|
||
function dismissTrackerAlert(mac) {
|
||
document.getElementById('trackerFollowingAlert').style.display = 'none';
|
||
// Reset the tracker history for this device
|
||
if (trackerHistory[mac]) {
|
||
trackerHistory[mac].firstSeen = Date.now();
|
||
trackerHistory[mac].seenCount = 0;
|
||
}
|
||
}
|
||
|
||
// Handle discovered Bluetooth device (called from batched update)
|
||
function handleBtDeviceImmediate(device) {
|
||
const isNew = !btDevices[device.mac];
|
||
|
||
// Check for Find My network
|
||
const findMyInfo = detectFindMyDevice(device);
|
||
if (findMyInfo) {
|
||
device.findmy = findMyInfo;
|
||
device.tracker = device.tracker || { name: findMyInfo.type };
|
||
}
|
||
|
||
// Merge with existing device data to preserve RSSI if not in update
|
||
if (btDevices[device.mac] && !device.rssi && btDevices[device.mac].rssi) {
|
||
device.rssi = btDevices[device.mac].rssi;
|
||
}
|
||
btDevices[device.mac] = device;
|
||
|
||
|
||
if (isNew) {
|
||
btDeviceCount++;
|
||
document.getElementById('btDeviceCount').textContent = btDeviceCount;
|
||
playAlert();
|
||
pulseSignal();
|
||
}
|
||
|
||
// Update selected device panel if this device is selected
|
||
if (selectedBtDevice === device.mac) {
|
||
updateBtSelectedDevice(device);
|
||
}
|
||
|
||
// Check for tracker following
|
||
checkTrackerFollowing(device);
|
||
|
||
// Track in device intelligence
|
||
trackDevice({
|
||
protocol: 'Bluetooth',
|
||
address: device.mac,
|
||
message: device.name,
|
||
model: device.manufacturer,
|
||
device_type: device.device_type || device.type || 'other'
|
||
});
|
||
|
||
// Update visualizations
|
||
addBtDeviceToRadar(device);
|
||
|
||
// Add device card
|
||
addBtDeviceCard(device, isNew);
|
||
|
||
// Update device list panel
|
||
updateBtDeviceList();
|
||
|
||
// Check for trackers and update tracker list
|
||
if (device.tracker || device.findmy) {
|
||
updateBtTrackerList();
|
||
}
|
||
}
|
||
|
||
// Currently selected BT device for signal tracking
|
||
let selectedBtDevice = null;
|
||
|
||
// Update the Bluetooth device list panel
|
||
function updateBtDeviceList() {
|
||
const listEl = document.getElementById('btDeviceList');
|
||
const countEl = document.getElementById('btListCount');
|
||
if (!listEl) return;
|
||
|
||
const devices = Object.values(btDevices);
|
||
countEl.textContent = devices.length;
|
||
|
||
if (devices.length === 0) {
|
||
listEl.innerHTML = '<div style="color: var(--text-dim); padding: 10px; text-align: center;">Start scanning to discover devices...</div>';
|
||
return;
|
||
}
|
||
|
||
// Sort by RSSI (strongest first)
|
||
devices.sort((a, b) => (b.rssi || -100) - (a.rssi || -100));
|
||
|
||
listEl.innerHTML = devices.map(d => {
|
||
const typeIcon = {
|
||
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
|
||
'computer': '💻', 'input': '⌨️', 'other': '📶'
|
||
}[d.device_type || d.type] || '📶';
|
||
|
||
const rssiColor = d.rssi > -50 ? 'var(--accent-green)' :
|
||
d.rssi > -70 ? 'var(--accent-cyan)' :
|
||
d.rssi > -85 ? 'var(--accent-orange)' : 'var(--accent-red)';
|
||
|
||
const isSelected = selectedBtDevice === d.mac;
|
||
const trackerBadge = d.findmy ? `<span style="color:#007aff;font-size:9px;">FindMy</span>` :
|
||
d.tracker ? `<span style="color:var(--accent-red);font-size:9px;">Tracker</span>` : '';
|
||
|
||
return `
|
||
<div onclick="selectBtDevice('${d.mac}')" style="
|
||
padding: 8px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||
cursor: pointer;
|
||
background: ${isSelected ? 'rgba(0,212,255,0.1)' : 'transparent'};
|
||
border-left: 2px solid ${isSelected ? 'var(--accent-cyan)' : 'transparent'};
|
||
" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='${isSelected ? 'rgba(0,212,255,0.1)' : 'transparent'}'">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||
<span>${typeIcon} ${escapeHtml(d.name || 'Unknown')}</span>
|
||
<span style="color:${rssiColor};font-weight:bold;">${d.rssi || '--'} dBm</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;color:var(--text-dim);font-size:10px;margin-top:2px;">
|
||
<span>${escapeHtml(d.mac)}</span>
|
||
${trackerBadge}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Select a BT device for details
|
||
function selectBtDevice(mac) {
|
||
selectedBtDevice = mac;
|
||
const device = btDevices[mac];
|
||
if (device) {
|
||
document.getElementById('btTargetMac').value = mac;
|
||
updateBtSelectedDevice(device);
|
||
}
|
||
updateBtDeviceList(); // Refresh to show selection
|
||
}
|
||
|
||
// Update the selected device details panel
|
||
function updateBtSelectedDevice(device) {
|
||
const panel = document.getElementById('btSelectedDevice');
|
||
if (!panel || !device) return;
|
||
|
||
const typeIcon = {
|
||
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
|
||
'computer': '💻', 'input': '⌨️', 'other': '📶'
|
||
}[device.device_type || device.type] || '📶';
|
||
|
||
const rssiColor = device.rssi > -50 ? 'var(--accent-green)' :
|
||
device.rssi > -70 ? 'var(--accent-cyan)' :
|
||
device.rssi > -85 ? 'var(--accent-orange)' : 'var(--accent-red)';
|
||
|
||
const signalBars = Math.max(1, Math.min(5, Math.floor((device.rssi + 100) / 10)));
|
||
const barsHtml = Array(5).fill(0).map((_, i) =>
|
||
`<div style="width:4px;height:${8 + i * 4}px;background:${i < signalBars ? rssiColor : 'rgba(255,255,255,0.1)'};border-radius:1px;"></div>`
|
||
).join('');
|
||
|
||
let trackerInfo = '';
|
||
if (device.findmy) {
|
||
trackerInfo = `
|
||
<div style="background:rgba(0,122,255,0.15);border:1px solid #007aff;border-radius:6px;padding:8px;margin-top:8px;">
|
||
<div style="color:#007aff;font-weight:bold;">🍎 ${escapeHtml(device.findmy.type)}</div>
|
||
<div style="color:var(--text-dim);font-size:10px;margin-top:2px;">${escapeHtml(device.findmy.network)} Network Device</div>
|
||
</div>`;
|
||
} else if (device.tracker) {
|
||
trackerInfo = `
|
||
<div style="background:rgba(255,100,100,0.15);border:1px solid var(--accent-red);border-radius:6px;padding:8px;margin-top:8px;">
|
||
<div style="color:var(--accent-red);font-weight:bold;">📍 ${escapeHtml(device.tracker.name)}</div>
|
||
<div style="color:var(--text-dim);font-size:10px;margin-top:2px;">Tracking Device Detected</div>
|
||
</div>`;
|
||
}
|
||
|
||
panel.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;">
|
||
<div>
|
||
<div style="font-size:16px;font-weight:bold;color:var(--accent-cyan);">${typeIcon} ${escapeHtml(device.name || 'Unknown Device')}</div>
|
||
<div style="color:var(--text-dim);font-size:10px;margin-top:2px;">${escapeHtml((device.device_type || device.type || 'unknown').toUpperCase())}</div>
|
||
</div>
|
||
<div style="text-align:right;">
|
||
<div style="font-size:18px;font-weight:bold;color:${rssiColor};">${device.rssi || '--'} dBm</div>
|
||
<div style="display:flex;gap:2px;justify-content:flex-end;align-items:flex-end;height:24px;margin-top:4px;">${barsHtml}</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
|
||
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
|
||
<div style="color:var(--text-dim);font-size:9px;">MAC ADDRESS</div>
|
||
<div style="font-family:monospace;font-size:11px;">${escapeHtml(device.mac)}</div>
|
||
</div>
|
||
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
|
||
<div style="color:var(--text-dim);font-size:9px;">MANUFACTURER</div>
|
||
<div style="font-size:11px;">${escapeHtml(device.manufacturer || 'Unknown')}</div>
|
||
</div>
|
||
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
|
||
<div style="color:var(--text-dim);font-size:9px;">ADDRESS TYPE</div>
|
||
<div style="font-size:11px;">${escapeHtml(device.address_type || 'Unknown')}</div>
|
||
</div>
|
||
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
|
||
<div style="color:var(--text-dim);font-size:9px;">LAST SEEN</div>
|
||
<div style="font-size:11px;">${device.last_seen ? new Date(device.last_seen * 1000).toLocaleTimeString() : 'Now'}</div>
|
||
</div>
|
||
</div>
|
||
${trackerInfo}
|
||
<div style="display:flex;gap:6px;margin-top:10px;">
|
||
<button class="preset-btn" onclick="btEnumServicesFor('${escapeAttr(device.mac)}')" style="flex:1;font-size:10px;padding:6px;">Enumerate Services</button>
|
||
<button class="preset-btn" onclick="copyToClipboard('${escapeAttr(device.mac)}')" style="flex:1;font-size:10px;padding:6px;">Copy MAC</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Copy text to clipboard helper
|
||
function copyToClipboard(text) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
showNotification('Copied', text);
|
||
}).catch(() => {
|
||
showInfo('Failed to copy to clipboard');
|
||
});
|
||
}
|
||
|
||
// Update tracker list panel
|
||
function updateBtTrackerList() {
|
||
const listEl = document.getElementById('btTrackerList');
|
||
if (!listEl) return;
|
||
|
||
const trackers = Object.values(btDevices).filter(d => d.tracker || d.findmy);
|
||
|
||
if (trackers.length === 0) {
|
||
listEl.innerHTML = '<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for AirTags, Tiles, and other trackers...</div>';
|
||
return;
|
||
}
|
||
|
||
listEl.innerHTML = trackers.map(d => {
|
||
const icon = d.findmy ? '🍎' : '📍';
|
||
const type = d.findmy ? d.findmy.type : (d.tracker ? d.tracker.name : 'Unknown');
|
||
const color = d.findmy ? '#007aff' : 'var(--accent-red)';
|
||
|
||
return `
|
||
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||
<div style="display:flex;justify-content:space-between;">
|
||
<span style="color:${color};">${icon} ${escapeHtml(type)}</span>
|
||
<span style="color:var(--text-dim);">${d.rssi || '--'} dBm</span>
|
||
</div>
|
||
<div style="color:var(--text-dim);font-size:10px;">${escapeHtml(d.mac)}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Add Bluetooth device card to device list panel
|
||
function addBtDeviceCard(device, isNew) {
|
||
// Add to new device list panel
|
||
const deviceList = document.getElementById('btDeviceListContent');
|
||
if (deviceList) {
|
||
// Remove placeholder if present
|
||
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder && placeholder.textContent.includes('Start scanning')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
let card = document.getElementById('btcard_' + device.mac.replace(/:/g, ''));
|
||
const devType = device.device_type || device.type || 'other';
|
||
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = 'btcard_' + device.mac.replace(/:/g, '');
|
||
card.className = 'sensor-card bt-device-card' +
|
||
(device.findmy ? ' findmy' : '') +
|
||
(device.tracker && !device.findmy ? ' tracker' : '');
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = () => selectBtDevice(device.mac);
|
||
deviceList.insertBefore(card, deviceList.firstChild);
|
||
|
||
// Update device count
|
||
const countEl = document.getElementById('btDeviceListCount');
|
||
if (countEl) countEl.textContent = Object.keys(btDevices).length;
|
||
}
|
||
|
||
const typeIcon = {
|
||
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
|
||
'computer': '💻', 'input': '⌨️', 'other': '📶'
|
||
}[devType] || '📶';
|
||
|
||
// Handle signal strength
|
||
const rssi = device.rssi || -100;
|
||
const signalBars = Math.max(0, Math.min(5, Math.floor((rssi + 100) / 15)));
|
||
const signalDisplay = rssi > -100 ? `${rssi} dBm` : 'N/A';
|
||
|
||
const findMyBadge = device.findmy
|
||
? `<span style="background: #007aff; color: #fff; padding: 2px 6px; border-radius: 3px; font-size: 9px; margin-left: 5px;">${device.findmy.network.toUpperCase()}</span>`
|
||
: '';
|
||
|
||
const trackerBadge = device.tracker && !device.findmy
|
||
? `<span style="background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 9px; margin-left: 5px;">TRACKER</span>`
|
||
: '';
|
||
|
||
card.innerHTML = `
|
||
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||
<span class="device-name" style="color: var(--accent-purple);">${typeIcon} ${escapeHtml(device.name || 'Unknown')}${findMyBadge}${trackerBadge}</span>
|
||
<span style="font-size: 10px; color: var(--text-dim);">${escapeHtml(devType.toUpperCase())}</span>
|
||
</div>
|
||
<div class="sensor-data">
|
||
<div class="data-item">
|
||
<div class="data-label">MAC</div>
|
||
<div class="data-value" style="font-size: 11px;">${escapeHtml(device.mac)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Manufacturer</div>
|
||
<div class="data-value">${escapeHtml(device.manufacturer || 'Unknown')}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Signal</div>
|
||
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 5px;">
|
||
<button class="preset-btn" onclick="event.stopPropagation(); btTargetDevice('${escapeAttr(device.mac)}')" style="font-size: 10px; padding: 4px 8px;">Target</button>
|
||
<button class="preset-btn" onclick="event.stopPropagation(); btEnumServicesFor('${escapeAttr(device.mac)}')" style="font-size: 10px; padding: 4px 8px;">Services</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Update statistics panels
|
||
updateBtStatsPanels();
|
||
}
|
||
|
||
// Select a Bluetooth device
|
||
function selectBtDevice(mac) {
|
||
selectedBtDevice = mac;
|
||
const device = btDevices[mac];
|
||
if (device) {
|
||
updateBtSelectedDevice(device);
|
||
}
|
||
}
|
||
|
||
// Update Bluetooth statistics panels
|
||
function updateBtStatsPanels() {
|
||
const devices = Object.values(btDevices);
|
||
|
||
// Device type counts
|
||
let phones = 0, computers = 0, audio = 0, wearables = 0, other = 0;
|
||
let strong = 0, medium = 0, weak = 0;
|
||
|
||
devices.forEach(d => {
|
||
const devType = d.device_type || d.type || 'other';
|
||
if (devType === 'phone') phones++;
|
||
else if (devType === 'computer') computers++;
|
||
else if (devType === 'audio') audio++;
|
||
else if (devType === 'wearable') wearables++;
|
||
else other++;
|
||
|
||
const rssi = d.rssi || -100;
|
||
if (rssi >= -50) strong++;
|
||
else if (rssi >= -70) medium++;
|
||
else weak++;
|
||
});
|
||
|
||
// Update type counts
|
||
const phoneEl = document.getElementById('btPhoneCount');
|
||
const compEl = document.getElementById('btComputerCount');
|
||
const audioEl = document.getElementById('btAudioCount');
|
||
const wearEl = document.getElementById('btWearableCount');
|
||
const otherEl = document.getElementById('btOtherCount');
|
||
if (phoneEl) phoneEl.textContent = phones;
|
||
if (compEl) compEl.textContent = computers;
|
||
if (audioEl) audioEl.textContent = audio;
|
||
if (wearEl) wearEl.textContent = wearables;
|
||
if (otherEl) otherEl.textContent = other;
|
||
|
||
// Update signal distribution
|
||
const total = devices.length || 1;
|
||
const strongBar = document.getElementById('btSignalStrong');
|
||
const mediumBar = document.getElementById('btSignalMedium');
|
||
const weakBar = document.getElementById('btSignalWeak');
|
||
const strongCount = document.getElementById('btSignalStrongCount');
|
||
const mediumCount = document.getElementById('btSignalMediumCount');
|
||
const weakCount = document.getElementById('btSignalWeakCount');
|
||
|
||
if (strongBar) strongBar.style.width = (strong / total * 100) + '%';
|
||
if (mediumBar) mediumBar.style.width = (medium / total * 100) + '%';
|
||
if (weakBar) weakBar.style.width = (weak / total * 100) + '%';
|
||
if (strongCount) strongCount.textContent = strong;
|
||
if (mediumCount) mediumCount.textContent = medium;
|
||
if (weakCount) weakCount.textContent = weak;
|
||
|
||
// Update FindMy list
|
||
updateBtFindMyList();
|
||
}
|
||
|
||
// Update FindMy device list
|
||
function updateBtFindMyList() {
|
||
const listEl = document.getElementById('btFindMyList');
|
||
if (!listEl) return;
|
||
|
||
const findMyDevices = Object.values(btDevices).filter(d => d.findmy);
|
||
|
||
if (findMyDevices.length === 0) {
|
||
listEl.innerHTML = '<div style="color: var(--text-dim); padding: 10px; text-align: center;">Scanning for FindMy-compatible devices...</div>';
|
||
return;
|
||
}
|
||
|
||
listEl.innerHTML = findMyDevices.map(d => `
|
||
<div style="display: flex; justify-content: space-between; padding: 5px 8px; background: rgba(0,122,255,0.1); border-radius: 3px; margin-bottom: 4px; cursor: pointer;" onclick="selectBtDevice('${escapeAttr(d.mac)}')">
|
||
<span>${d.findmy.icon || '📍'} ${escapeHtml(d.name || d.findmy.type)}</span>
|
||
<span style="color: #007aff;">${d.rssi || '--'} dBm</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Target a Bluetooth device
|
||
function btTargetDevice(mac) {
|
||
document.getElementById('btTargetMac').value = mac;
|
||
showInfo('Targeted: ' + mac);
|
||
}
|
||
|
||
// Enumerate services for a device
|
||
function btEnumServicesFor(mac) {
|
||
document.getElementById('btTargetMac').value = mac;
|
||
btEnumServices();
|
||
}
|
||
|
||
// Enumerate services
|
||
function btEnumServices() {
|
||
const mac = document.getElementById('btTargetMac').value;
|
||
if (!mac) { alert('Enter target MAC'); return; }
|
||
|
||
showInfo('Enumerating services for ' + mac + '...');
|
||
|
||
fetch('/bt/enum', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mac: mac })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
let msg = 'Services for ' + mac + ': ';
|
||
if (data.services.length === 0) {
|
||
msg += 'None found';
|
||
} else {
|
||
msg += data.services.map(s => s.name).join(', ');
|
||
}
|
||
showInfo(msg);
|
||
} else {
|
||
showInfo('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Initialize Bluetooth radar
|
||
function initBtRadar() {
|
||
const canvas = document.getElementById('btRadarCanvas');
|
||
if (!canvas) return;
|
||
|
||
btRadarCtx = canvas.getContext('2d');
|
||
canvas.width = 150;
|
||
canvas.height = 150;
|
||
|
||
if (!btRadarAnimFrame) {
|
||
animateBtRadar();
|
||
}
|
||
}
|
||
|
||
// Animate Bluetooth radar
|
||
function animateBtRadar() {
|
||
if (!btRadarCtx) { btRadarAnimFrame = null; return; }
|
||
|
||
const canvas = btRadarCtx.canvas;
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 5;
|
||
|
||
btRadarCtx.fillStyle = 'rgba(0, 10, 20, 0.1)';
|
||
btRadarCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Grid circles
|
||
btRadarCtx.strokeStyle = 'rgba(138, 43, 226, 0.2)';
|
||
btRadarCtx.lineWidth = 1;
|
||
for (let r = radius / 4; r <= radius; r += radius / 4) {
|
||
btRadarCtx.beginPath();
|
||
btRadarCtx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
btRadarCtx.stroke();
|
||
}
|
||
|
||
// Sweep line (purple for BT)
|
||
btRadarCtx.strokeStyle = 'rgba(138, 43, 226, 0.8)';
|
||
btRadarCtx.lineWidth = 2;
|
||
btRadarCtx.beginPath();
|
||
btRadarCtx.moveTo(cx, cy);
|
||
btRadarCtx.lineTo(cx + Math.cos(btRadarAngle) * radius, cy + Math.sin(btRadarAngle) * radius);
|
||
btRadarCtx.stroke();
|
||
|
||
// Device blips
|
||
btRadarDevices.forEach(dev => {
|
||
const age = Date.now() - dev.timestamp;
|
||
const alpha = Math.max(0.1, 1 - age / 15000);
|
||
const color = dev.isTracker ? '255, 51, 102' : '138, 43, 226';
|
||
|
||
btRadarCtx.fillStyle = `rgba(${color}, ${alpha})`;
|
||
btRadarCtx.beginPath();
|
||
btRadarCtx.arc(dev.x, dev.y, dev.isTracker ? 6 : 4, 0, Math.PI * 2);
|
||
btRadarCtx.fill();
|
||
});
|
||
|
||
btRadarAngle += 0.025;
|
||
if (btRadarAngle > Math.PI * 2) btRadarAngle = 0;
|
||
|
||
btRadarAnimFrame = requestAnimationFrame(animateBtRadar);
|
||
}
|
||
|
||
// Add device to BT radar
|
||
function addBtDeviceToRadar(device) {
|
||
const canvas = document.getElementById('btRadarCanvas');
|
||
if (!canvas) return;
|
||
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 10;
|
||
|
||
// Random position based on MAC hash
|
||
let angle = 0;
|
||
for (let i = 0; i < device.mac.length; i++) {
|
||
angle += device.mac.charCodeAt(i);
|
||
}
|
||
angle = (angle % 360) * Math.PI / 180;
|
||
const r = radius * (0.3 + Math.random() * 0.6);
|
||
|
||
const x = cx + Math.cos(angle) * r;
|
||
const y = cy + Math.sin(angle) * r;
|
||
|
||
const existing = btRadarDevices.find(d => d.mac === device.mac);
|
||
if (existing) {
|
||
existing.timestamp = Date.now();
|
||
} else {
|
||
btRadarDevices.push({
|
||
x, y,
|
||
mac: device.mac,
|
||
isTracker: !!device.tracker,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
if (btRadarDevices.length > 50) btRadarDevices.shift();
|
||
}
|
||
|
||
// ============================================
|
||
// APRS Functions
|
||
// ============================================
|
||
let aprsMap = null;
|
||
let aprsMarkers = {};
|
||
let aprsEventSource = null;
|
||
let isAprsRunning = false;
|
||
let aprsPacketCount = 0;
|
||
let aprsStationCount = 0;
|
||
let aprsMeterLastUpdate = 0;
|
||
let aprsMeterCheckInterval = null;
|
||
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
|
||
|
||
// APRS user location (from GPS)
|
||
let aprsUserLocation = { lat: null, lon: null };
|
||
let aprsUserMarker = null;
|
||
|
||
// Calculate distance in miles using Haversine formula
|
||
function aprsCalculateDistanceMi(lat1, lon1, lat2, lon2) {
|
||
const R = 3958.8; // Earth's radius in miles
|
||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
return R * c;
|
||
}
|
||
|
||
// Update APRS user location from GPS
|
||
function updateAprsUserLocation(position) {
|
||
if (!position || !position.latitude || !position.longitude) return;
|
||
|
||
aprsUserLocation.lat = position.latitude;
|
||
aprsUserLocation.lon = position.longitude;
|
||
|
||
// Update user marker on map
|
||
if (aprsMap) {
|
||
if (aprsUserMarker) {
|
||
aprsUserMarker.setLatLng([position.latitude, position.longitude]);
|
||
} else {
|
||
aprsUserMarker = L.marker([position.latitude, position.longitude], {
|
||
icon: L.divIcon({
|
||
className: 'aprs-user-marker',
|
||
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
|
||
iconSize: [14, 14],
|
||
iconAnchor: [7, 7]
|
||
}),
|
||
zIndexOffset: 1000
|
||
}).bindPopup('Your Location (GPS)').addTo(aprsMap);
|
||
}
|
||
|
||
// Center map on first GPS fix
|
||
if (!aprsMap._gpsInitialized) {
|
||
aprsMap.setView([position.latitude, position.longitude], 8);
|
||
aprsMap._gpsInitialized = true;
|
||
}
|
||
}
|
||
|
||
// Show GPS indicator
|
||
const indicator = document.getElementById('aprsGpsIndicator');
|
||
if (indicator) indicator.style.display = 'inline-flex';
|
||
|
||
// Update distances in existing station list
|
||
updateAprsStationDistances();
|
||
}
|
||
|
||
// Update distances for all stations in the list
|
||
function updateAprsStationDistances() {
|
||
if (!aprsUserLocation.lat || !aprsUserLocation.lon) return;
|
||
|
||
// Update station list items
|
||
const listEl = document.getElementById('aprsStationList');
|
||
if (listEl) {
|
||
listEl.querySelectorAll('[data-callsign]').forEach(stationEl => {
|
||
const lat = parseFloat(stationEl.dataset.lat);
|
||
const lon = parseFloat(stationEl.dataset.lon);
|
||
if (!isNaN(lat) && !isNaN(lon)) {
|
||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
|
||
const distSpan = stationEl.querySelector('.aprs-distance');
|
||
if (distSpan) {
|
||
distSpan.textContent = dist.toFixed(1) + ' mi';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function checkAprsTools() {
|
||
fetch('/aprs/tools')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Update function bar tool indicators
|
||
const direwolfEl = document.getElementById('aprsStripDirewolf');
|
||
const multimonEl = document.getElementById('aprsStripMultimon');
|
||
|
||
if (direwolfEl) {
|
||
direwolfEl.className = 'strip-tool' + (data.direwolf ? ' ok' : '');
|
||
direwolfEl.title = 'direwolf: ' + (data.direwolf ? 'OK' : 'Missing');
|
||
}
|
||
if (multimonEl) {
|
||
multimonEl.className = 'strip-tool' + (data.multimon_ng ? ' ok' : '');
|
||
multimonEl.title = 'multimon-ng: ' + (data.multimon_ng ? 'OK' : 'Missing');
|
||
}
|
||
})
|
||
.catch(() => {
|
||
const direwolfEl = document.getElementById('aprsStripDirewolf');
|
||
const multimonEl = document.getElementById('aprsStripMultimon');
|
||
if (direwolfEl) {
|
||
direwolfEl.className = 'strip-tool';
|
||
direwolfEl.title = 'direwolf: Error';
|
||
}
|
||
if (multimonEl) {
|
||
multimonEl.className = 'strip-tool';
|
||
multimonEl.title = 'multimon-ng: Error';
|
||
}
|
||
});
|
||
}
|
||
|
||
function initAprsMap() {
|
||
if (aprsMap) return;
|
||
|
||
const mapContainer = document.getElementById('aprsMap');
|
||
if (!mapContainer) return;
|
||
|
||
// Use GPS location if available, otherwise default to center of US
|
||
const initialLat = aprsUserLocation.lat || gpsLastPosition?.latitude || 39.8283;
|
||
const initialLon = aprsUserLocation.lon || gpsLastPosition?.longitude || -98.5795;
|
||
const initialZoom = (aprsUserLocation.lat || gpsLastPosition?.latitude) ? 8 : 4;
|
||
|
||
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
||
|
||
// OpenStreetMap with roads - better for APRS station tracking
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
maxZoom: 19
|
||
}).addTo(aprsMap);
|
||
|
||
// Add user marker if GPS position is already available
|
||
if (gpsConnected && gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
|
||
updateAprsUserLocation(gpsLastPosition);
|
||
aprsMap._gpsInitialized = true;
|
||
}
|
||
|
||
// Update time display (both map header and function bar)
|
||
setInterval(() => {
|
||
const now = new Date();
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
|
||
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
|
||
|
||
const timeEl = document.getElementById('aprsMapTime');
|
||
if (timeEl) timeEl.textContent = timeStr;
|
||
|
||
const stripTimeEl = document.getElementById('aprsStripTime');
|
||
if (stripTimeEl) stripTimeEl.textContent = utcStr;
|
||
}, 1000);
|
||
}
|
||
|
||
function updateAprsStatus(state, freq) {
|
||
// Update function bar status
|
||
const stripDot = document.getElementById('aprsStripDot');
|
||
const stripStatus = document.getElementById('aprsStripStatus');
|
||
const stripFreq = document.getElementById('aprsStripFreq');
|
||
|
||
if (stripDot) {
|
||
stripDot.className = 'status-dot ' + state;
|
||
}
|
||
if (stripStatus) {
|
||
stripStatus.textContent = state.toUpperCase();
|
||
if (state === 'listening') {
|
||
stripStatus.style.color = 'var(--accent-cyan)';
|
||
} else if (state === 'tracking') {
|
||
stripStatus.style.color = 'var(--accent-green)';
|
||
} else if (state === 'error') {
|
||
stripStatus.style.color = 'var(--accent-red)';
|
||
} else {
|
||
stripStatus.style.color = '';
|
||
}
|
||
}
|
||
if (freq && stripFreq) {
|
||
stripFreq.textContent = freq;
|
||
}
|
||
}
|
||
|
||
function startAprs() {
|
||
// Get values from function bar controls
|
||
const region = document.getElementById('aprsStripRegion').value;
|
||
const device = getSelectedDevice();
|
||
const gain = document.getElementById('aprsStripGain').value;
|
||
|
||
fetch('/aprs/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ region, device: parseInt(device), gain: parseInt(gain) })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
isAprsRunning = true;
|
||
aprsPacketCount = 0;
|
||
aprsStationCount = 0;
|
||
// Update function bar buttons
|
||
document.getElementById('aprsStripStartBtn').style.display = 'none';
|
||
document.getElementById('aprsStripStopBtn').style.display = 'inline-block';
|
||
// Update map status
|
||
document.getElementById('aprsMapStatus').textContent = 'TRACKING';
|
||
document.getElementById('aprsMapStatus').style.color = 'var(--accent-green)';
|
||
// Update function bar status
|
||
updateAprsStatus('listening', data.frequency);
|
||
// Reset function bar stats
|
||
document.getElementById('aprsStripStations').textContent = '0';
|
||
document.getElementById('aprsStripPackets').textContent = '0';
|
||
document.getElementById('aprsStripSignal').textContent = '--';
|
||
// Disable controls while running
|
||
document.getElementById('aprsStripRegion').disabled = true;
|
||
document.getElementById('aprsStripGain').disabled = true;
|
||
startAprsMeterCheck();
|
||
startAprsStream();
|
||
} else {
|
||
alert('APRS Error: ' + data.message);
|
||
updateAprsStatus('error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
alert('APRS Error: ' + err);
|
||
updateAprsStatus('error');
|
||
});
|
||
}
|
||
|
||
function stopAprs() {
|
||
fetch('/aprs/stop', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
isAprsRunning = false;
|
||
// Update function bar buttons
|
||
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
|
||
document.getElementById('aprsStripStopBtn').style.display = 'none';
|
||
// Update map status
|
||
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
|
||
document.getElementById('aprsMapStatus').style.color = '';
|
||
// Reset function bar status
|
||
updateAprsStatus('standby');
|
||
document.getElementById('aprsStripFreq').textContent = '--';
|
||
document.getElementById('aprsStripSignal').textContent = '--';
|
||
// Re-enable controls
|
||
document.getElementById('aprsStripRegion').disabled = false;
|
||
document.getElementById('aprsStripGain').disabled = false;
|
||
// Remove signal quality class
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
if (signalStat) {
|
||
signalStat.classList.remove('good', 'warning', 'poor');
|
||
}
|
||
// Stop meter check interval
|
||
stopAprsMeterCheck();
|
||
if (aprsEventSource) {
|
||
aprsEventSource.close();
|
||
aprsEventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function startAprsStream() {
|
||
if (aprsEventSource) aprsEventSource.close();
|
||
aprsEventSource = new EventSource('/aprs/stream');
|
||
|
||
aprsEventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'aprs') {
|
||
aprsPacketCount++;
|
||
// Update map footer and function bar
|
||
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
|
||
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
|
||
// Switch to tracking state on first packet
|
||
const dot = document.getElementById('aprsStripDot');
|
||
if (dot && !dot.classList.contains('tracking')) {
|
||
updateAprsStatus('tracking');
|
||
}
|
||
processAprsPacket(data);
|
||
} else if (data.type === 'meter') {
|
||
// Update signal indicator in function bar
|
||
updateAprsMeter(data.level);
|
||
}
|
||
};
|
||
|
||
aprsEventSource.onerror = function () {
|
||
console.error('APRS stream error');
|
||
updateAprsStatus('error');
|
||
};
|
||
}
|
||
|
||
// Signal Meter Functions
|
||
function resetAprsMeter() {
|
||
aprsMeterLastUpdate = 0;
|
||
// Reset function bar signal indicator
|
||
const signalEl = document.getElementById('aprsStripSignal');
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
if (signalEl) signalEl.textContent = '--';
|
||
if (signalStat) signalStat.classList.remove('good', 'warning', 'poor');
|
||
}
|
||
|
||
function updateAprsMeter(level) {
|
||
aprsMeterLastUpdate = Date.now();
|
||
|
||
// Update function bar signal indicator
|
||
const signalEl = document.getElementById('aprsStripSignal');
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
|
||
if (signalEl) {
|
||
// Show signal level as bars
|
||
if (level >= 60) {
|
||
signalEl.textContent = '●●●';
|
||
} else if (level >= 30) {
|
||
signalEl.textContent = '●●○';
|
||
} else if (level >= 10) {
|
||
signalEl.textContent = '●○○';
|
||
} else {
|
||
signalEl.textContent = '○○○';
|
||
}
|
||
}
|
||
|
||
if (signalStat) {
|
||
signalStat.classList.remove('good', 'warning', 'poor');
|
||
if (level >= 60) {
|
||
signalStat.classList.add('good');
|
||
} else if (level >= 30) {
|
||
signalStat.classList.add('warning');
|
||
} else {
|
||
signalStat.classList.add('poor');
|
||
}
|
||
}
|
||
}
|
||
|
||
function startAprsMeterCheck() {
|
||
// Check for no-signal state every second
|
||
aprsMeterCheckInterval = setInterval(function () {
|
||
if (aprsMeterLastUpdate > 0 && (Date.now() - aprsMeterLastUpdate) > APRS_METER_TIMEOUT) {
|
||
// No meter updates for 5 seconds - show no-signal state
|
||
const signalEl = document.getElementById('aprsStripSignal');
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
if (signalEl) signalEl.textContent = '○○○';
|
||
if (signalStat) {
|
||
signalStat.classList.remove('good', 'warning');
|
||
signalStat.classList.add('poor');
|
||
}
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function stopAprsMeterCheck() {
|
||
if (aprsMeterCheckInterval) {
|
||
clearInterval(aprsMeterCheckInterval);
|
||
aprsMeterCheckInterval = null;
|
||
}
|
||
}
|
||
|
||
function processAprsPacket(packet) {
|
||
// Update packet log
|
||
const logEl = document.getElementById('aprsPacketLog');
|
||
const logEntry = document.createElement('div');
|
||
logEntry.style.cssText = 'padding: 3px 0; border-bottom: 1px solid var(--border-color);';
|
||
|
||
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
const callsign = packet.callsign || 'UNKNOWN';
|
||
const packetType = packet.packet_type || 'unknown';
|
||
|
||
logEntry.innerHTML = `<span style="color: var(--text-muted);">${time}</span> <span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span> <span style="color: var(--accent-green);">[${packetType}]</span>`;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = logEl.querySelector('div[style*="color: var(--text-muted)"]');
|
||
if (placeholder && placeholder.textContent.includes('Waiting')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
logEl.insertBefore(logEntry, logEl.firstChild);
|
||
|
||
// Keep log manageable
|
||
while (logEl.children.length > 100) {
|
||
logEl.removeChild(logEl.lastChild);
|
||
}
|
||
|
||
// Update map if position data
|
||
if (packet.lat && packet.lon && aprsMap) {
|
||
updateAprsMarker(packet);
|
||
}
|
||
|
||
// Update station list
|
||
updateAprsStationList(packet);
|
||
}
|
||
|
||
function updateAprsMarker(packet) {
|
||
const callsign = packet.callsign;
|
||
|
||
// Calculate distance if user location available
|
||
let distStr = '';
|
||
if (aprsUserLocation.lat && aprsUserLocation.lon) {
|
||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
|
||
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
|
||
}
|
||
|
||
if (aprsMarkers[callsign]) {
|
||
// Update existing marker position and popup
|
||
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
|
||
aprsMarkers[callsign].setPopupContent(`
|
||
<div style="font-family: monospace;">
|
||
<strong>${callsign}</strong><br>
|
||
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
|
||
${distStr}
|
||
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
|
||
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
|
||
${packet.course ? `Course: ${packet.course}°<br>` : ''}
|
||
</div>
|
||
`);
|
||
} else {
|
||
// Create new marker
|
||
aprsStationCount++;
|
||
// Update map footer and function bar
|
||
document.getElementById('aprsStationCount').textContent = aprsStationCount;
|
||
document.getElementById('aprsStripStations').textContent = aprsStationCount;
|
||
|
||
const icon = L.divIcon({
|
||
className: 'aprs-marker',
|
||
html: `<div style="background: var(--accent-cyan); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; white-space: nowrap;">${callsign}</div>`,
|
||
iconSize: [80, 20],
|
||
iconAnchor: [40, 10]
|
||
});
|
||
|
||
const marker = L.marker([packet.lat, packet.lon], { icon: icon }).addTo(aprsMap);
|
||
|
||
marker.bindPopup(`
|
||
<div style="font-family: monospace;">
|
||
<strong>${callsign}</strong><br>
|
||
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
|
||
${distStr}
|
||
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
|
||
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
|
||
${packet.course ? `Course: ${packet.course}°<br>` : ''}
|
||
</div>
|
||
`);
|
||
|
||
aprsMarkers[callsign] = marker;
|
||
}
|
||
}
|
||
|
||
function updateAprsStationList(packet) {
|
||
const listEl = document.getElementById('aprsStationList');
|
||
const callsign = packet.callsign;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = listEl.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder && placeholder.textContent.includes('No stations')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Check if station already exists
|
||
let stationEl = listEl.querySelector(`[data-callsign="${callsign}"]`);
|
||
|
||
if (!stationEl) {
|
||
stationEl = document.createElement('div');
|
||
stationEl.dataset.callsign = callsign;
|
||
stationEl.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); cursor: pointer;';
|
||
stationEl.onclick = () => {
|
||
if (aprsMarkers[callsign] && aprsMap) {
|
||
aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10);
|
||
aprsMarkers[callsign].openPopup();
|
||
}
|
||
};
|
||
listEl.insertBefore(stationEl, listEl.firstChild);
|
||
}
|
||
|
||
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||
const hasPos = packet.lat && packet.lon;
|
||
|
||
// Store lat/lon in dataset for distance updates
|
||
if (hasPos) {
|
||
stationEl.dataset.lat = packet.lat;
|
||
stationEl.dataset.lon = packet.lon;
|
||
}
|
||
|
||
// Calculate distance if user location available
|
||
let distStr = '';
|
||
if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) {
|
||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
|
||
distStr = `<span class="aprs-distance" style="color: var(--accent-green);">${dist.toFixed(1)} mi</span>`;
|
||
} else if (hasPos) {
|
||
distStr = `<span class="aprs-distance" style="color: var(--text-muted);">-- mi</span>`;
|
||
}
|
||
|
||
stationEl.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span>
|
||
<span style="font-size: 9px; color: var(--text-muted);">${time}</span>
|
||
</div>
|
||
<div style="font-size: 9px; color: var(--text-secondary); margin-top: 2px; display: flex; justify-content: space-between;">
|
||
<span>${packet.packet_type || 'unknown'} ${hasPos ? `| ${packet.lat.toFixed(2)}, ${packet.lon.toFixed(2)}` : ''}</span>
|
||
${distStr}
|
||
</div>
|
||
`;
|
||
|
||
// Keep list manageable
|
||
while (listEl.children.length > 50) {
|
||
listEl.removeChild(listEl.lastChild);
|
||
}
|
||
}
|
||
|
||
|
||
// ============================================
|
||
// SATELLITE MODE FUNCTIONS
|
||
// ============================================
|
||
|
||
function getLocation() {
|
||
if (navigator.geolocation) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
position => {
|
||
document.getElementById('obsLat').value = position.coords.latitude.toFixed(4);
|
||
document.getElementById('obsLon').value = position.coords.longitude.toFixed(4);
|
||
showInfo('Location updated!');
|
||
},
|
||
error => {
|
||
alert('Could not get location: ' + error.message);
|
||
}
|
||
);
|
||
} else {
|
||
alert('Geolocation not supported by browser');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// GPS FUNCTIONS (gpsd auto-connect)
|
||
// ============================================
|
||
|
||
async function autoConnectGps() {
|
||
// Automatically try to connect to gpsd on page load
|
||
try {
|
||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'connected') {
|
||
gpsConnected = true;
|
||
startGpsStream();
|
||
showGpsIndicator(true);
|
||
console.log('GPS: Auto-connected to gpsd');
|
||
if (data.position) {
|
||
updateLocationFromGps(data.position);
|
||
}
|
||
} else {
|
||
console.log('GPS: gpsd not available -', data.message);
|
||
}
|
||
} catch (e) {
|
||
console.log('GPS: Auto-connect failed -', e.message);
|
||
}
|
||
}
|
||
|
||
let gpsReconnectTimeout = null;
|
||
|
||
function startGpsStream() {
|
||
if (gpsEventSource) {
|
||
gpsEventSource.close();
|
||
}
|
||
if (gpsReconnectTimeout) {
|
||
clearTimeout(gpsReconnectTimeout);
|
||
gpsReconnectTimeout = null;
|
||
}
|
||
|
||
gpsEventSource = new EventSource('/gps/stream');
|
||
gpsEventSource.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'position') {
|
||
gpsLastPosition = data;
|
||
updateLocationFromGps(data);
|
||
}
|
||
} catch (e) {
|
||
console.error('GPS parse error:', e);
|
||
}
|
||
};
|
||
gpsEventSource.onerror = (e) => {
|
||
// Don't log every error - connection suspends are normal
|
||
if (gpsEventSource) {
|
||
gpsEventSource.close();
|
||
gpsEventSource = null;
|
||
}
|
||
// Auto-reconnect after 5 seconds if still connected
|
||
if (gpsConnected && !gpsReconnectTimeout) {
|
||
gpsReconnectTimeout = setTimeout(() => {
|
||
gpsReconnectTimeout = null;
|
||
if (gpsConnected) {
|
||
startGpsStream();
|
||
}
|
||
}, 5000);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Reconnect GPS stream when tab becomes visible
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden && gpsConnected && !gpsEventSource) {
|
||
startGpsStream();
|
||
}
|
||
});
|
||
|
||
function updateLocationFromGps(position) {
|
||
if (!position || !position.latitude || !position.longitude) {
|
||
return;
|
||
}
|
||
|
||
// Update satellite observer location
|
||
const satLatInput = document.getElementById('obsLat');
|
||
const satLonInput = document.getElementById('obsLon');
|
||
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
|
||
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
|
||
|
||
// Update observerLocation
|
||
observerLocation.lat = position.latitude;
|
||
observerLocation.lon = position.longitude;
|
||
|
||
// Update APRS user location
|
||
updateAprsUserLocation(position);
|
||
}
|
||
|
||
function showGpsIndicator(show) {
|
||
// Show/hide all GPS indicators (by class and by ID)
|
||
document.querySelectorAll('.gps-indicator').forEach(el => {
|
||
el.style.display = show ? 'inline-flex' : 'none';
|
||
});
|
||
// Also target specific IDs in case class selector doesn't work
|
||
['satGpsIndicator', 'aprsGpsIndicator'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = show ? 'inline-flex' : 'none';
|
||
});
|
||
}
|
||
|
||
function initPolarPlot() {
|
||
const canvas = document.getElementById('polarPlotCanvas');
|
||
if (!canvas) return;
|
||
const container = canvas.parentElement;
|
||
const size = Math.min(container.offsetWidth, 400);
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
drawPolarPlot();
|
||
}
|
||
|
||
function drawPolarPlot(pass = null) {
|
||
const canvas = document.getElementById('polarPlotCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const size = canvas.width;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = size / 2 - 30;
|
||
|
||
// Clear
|
||
ctx.fillStyle = '#0a0a0a';
|
||
ctx.fillRect(0, 0, size, size);
|
||
|
||
// Draw elevation rings
|
||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
|
||
ctx.lineWidth = 1;
|
||
for (let el = 0; el <= 90; el += 30) {
|
||
const r = radius * (90 - el) / 90;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// Label
|
||
if (el > 0) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '10px JetBrains Mono';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(el + '°', cx, cy - r + 12);
|
||
}
|
||
}
|
||
|
||
// Draw azimuth lines
|
||
for (let az = 0; az < 360; az += 45) {
|
||
const rad = az * Math.PI / 180;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.lineTo(cx + Math.sin(rad) * radius, cy - Math.cos(rad) * radius);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Draw cardinal directions
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.font = 'bold 14px Rajdhani';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('N', cx, cy - radius - 8);
|
||
ctx.fillStyle = '#888';
|
||
ctx.fillText('S', cx, cy + radius + 16);
|
||
ctx.fillText('E', cx + radius + 12, cy + 4);
|
||
ctx.fillText('W', cx - radius - 12, cy + 4);
|
||
|
||
// Draw zenith
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Draw selected pass trajectory
|
||
if (pass && pass.trajectory) {
|
||
ctx.strokeStyle = pass.color || '#00ff00';
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([5, 3]);
|
||
ctx.beginPath();
|
||
|
||
pass.trajectory.forEach((point, i) => {
|
||
// Backend returns 'el' and 'az' properties
|
||
const el = point.el !== undefined ? point.el : point.elevation;
|
||
const az = point.az !== undefined ? point.az : point.azimuth;
|
||
const r = radius * (90 - el) / 90;
|
||
const rad = az * Math.PI / 180;
|
||
const x = cx + Math.sin(rad) * r;
|
||
const y = cy - Math.cos(rad) * r;
|
||
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Draw max elevation point
|
||
const maxPoint = pass.trajectory.reduce((max, p) => {
|
||
const pEl = p.el !== undefined ? p.el : p.elevation;
|
||
const maxEl = max.el !== undefined ? max.el : max.elevation;
|
||
return pEl > maxEl ? p : max;
|
||
}, { el: 0, elevation: 0 });
|
||
const maxEl = maxPoint.el !== undefined ? maxPoint.el : maxPoint.elevation;
|
||
const maxAz = maxPoint.az !== undefined ? maxPoint.az : maxPoint.azimuth;
|
||
const maxR = radius * (90 - maxEl) / 90;
|
||
const maxRad = maxAz * Math.PI / 180;
|
||
const maxX = cx + Math.sin(maxRad) * maxR;
|
||
const maxY = cy - Math.cos(maxRad) * maxR;
|
||
|
||
ctx.fillStyle = pass.color || '#00ff00';
|
||
ctx.beginPath();
|
||
ctx.arc(maxX, maxY, 6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Label
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '11px JetBrains Mono';
|
||
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
|
||
}
|
||
}
|
||
|
||
function calculatePasses() {
|
||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||
const hours = parseInt(document.getElementById('predictionHours').value);
|
||
const minEl = parseInt(document.getElementById('minElevation').value);
|
||
|
||
const satellites = getSelectedSatellites();
|
||
|
||
if (satellites.length === 0) {
|
||
alert('Please select at least one satellite to track');
|
||
return;
|
||
}
|
||
|
||
fetch('/satellite/predict', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ lat, lon, hours, minEl, satellites })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
satellitePasses = data.passes;
|
||
renderPassList();
|
||
document.getElementById('passCount').textContent = data.passes.length;
|
||
if (data.passes.length > 0) {
|
||
selectPass(0);
|
||
document.getElementById('satelliteCountdown').style.display = 'block';
|
||
updateSatelliteCountdown();
|
||
startCountdownTimer();
|
||
} else {
|
||
document.getElementById('satelliteCountdown').style.display = 'none';
|
||
}
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderPassList() {
|
||
const container = document.getElementById('passList');
|
||
container.innerHTML = '';
|
||
|
||
if (satellitePasses.length === 0) {
|
||
container.innerHTML = '<div style="color: #666; text-align: center; padding: 30px;">No passes found for selected criteria.</div>';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('passListCount').textContent = satellitePasses.length + ' passes';
|
||
|
||
satellitePasses.forEach((pass, index) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'pass-card' + (index === 0 ? ' active' : '');
|
||
card.onclick = () => selectPass(index);
|
||
|
||
const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair';
|
||
|
||
card.innerHTML = `
|
||
<div class="pass-satellite">${pass.satellite}</div>
|
||
<div class="pass-time">${pass.startTime}</div>
|
||
<div class="pass-details">
|
||
<div>Max El: <span>${pass.maxEl}°</span></div>
|
||
<div>Duration: <span>${pass.duration}m</span></div>
|
||
<div class="pass-quality ${quality}">${quality.toUpperCase()}</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function selectPass(index) {
|
||
selectedPass = satellitePasses[index];
|
||
selectedPassIndex = index;
|
||
document.querySelectorAll('.pass-card').forEach((card, i) => {
|
||
card.classList.toggle('active', i === index);
|
||
});
|
||
drawPolarPlot(selectedPass);
|
||
updateGroundTrack(selectedPass);
|
||
// Update countdown to show selected pass
|
||
updateSatelliteCountdown();
|
||
// Start real-time position updates for full orbit track
|
||
startSatellitePositionUpdates();
|
||
// Fetch position immediately
|
||
updateRealTimePosition();
|
||
}
|
||
|
||
// Ground Track Map
|
||
let groundTrackMap = null;
|
||
let groundTrackLine = null;
|
||
let satMarker = null;
|
||
let observerMarker = null;
|
||
let satPositionInterval = null;
|
||
|
||
function initGroundTrackMap() {
|
||
const mapContainer = document.getElementById('groundTrackMap');
|
||
if (!mapContainer || groundTrackMap) return;
|
||
|
||
groundTrackMap = L.map('groundTrackMap', {
|
||
center: [20, 0],
|
||
zoom: 1,
|
||
zoomControl: true,
|
||
attributionControl: false
|
||
});
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
attribution: '© OpenStreetMap contributors'
|
||
}).addTo(groundTrackMap);
|
||
|
||
// Add observer marker
|
||
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
|
||
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
|
||
observerMarker = L.circleMarker([lat, lon], {
|
||
radius: 8,
|
||
fillColor: '#ff6600',
|
||
color: '#fff',
|
||
weight: 2,
|
||
fillOpacity: 1
|
||
}).addTo(groundTrackMap).bindPopup('Observer Location');
|
||
}
|
||
|
||
function updateGroundTrack(pass) {
|
||
if (!groundTrackMap) initGroundTrackMap();
|
||
if (!pass || !pass.groundTrack) return;
|
||
|
||
// Remove old track and marker
|
||
if (groundTrackLine) {
|
||
groundTrackMap.removeLayer(groundTrackLine);
|
||
groundTrackLine = null;
|
||
}
|
||
if (satMarker) {
|
||
groundTrackMap.removeLayer(satMarker);
|
||
satMarker = null;
|
||
}
|
||
if (orbitTrackLine) {
|
||
groundTrackMap.removeLayer(orbitTrackLine);
|
||
orbitTrackLine = null;
|
||
}
|
||
if (pastOrbitLine) {
|
||
groundTrackMap.removeLayer(pastOrbitLine);
|
||
pastOrbitLine = null;
|
||
}
|
||
|
||
// Split ground track only at true antimeridian crossings (±180° line)
|
||
const segments = [];
|
||
let currentSegment = [];
|
||
for (let i = 0; i < pass.groundTrack.length; i++) {
|
||
const p = pass.groundTrack[i];
|
||
if (currentSegment.length > 0) {
|
||
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||
// Only split when crossing the antimeridian (one side > 90, other < -90)
|
||
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
|
||
if (crossesAntimeridian) {
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
currentSegment = [];
|
||
}
|
||
}
|
||
currentSegment.push([p.lat, p.lon]);
|
||
}
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
|
||
// Draw ground track segments
|
||
groundTrackLine = L.layerGroup();
|
||
const allCoords = [];
|
||
segments.forEach(seg => {
|
||
L.polyline(seg, {
|
||
color: pass.color || '#00ff00',
|
||
weight: 2,
|
||
opacity: 0.8,
|
||
dashArray: '5, 5'
|
||
}).addTo(groundTrackLine);
|
||
allCoords.push(...seg);
|
||
});
|
||
groundTrackLine.addTo(groundTrackMap);
|
||
|
||
// Add current position marker
|
||
if (pass.currentPosition) {
|
||
satMarker = L.marker([pass.currentPosition.lat, pass.currentPosition.lon], {
|
||
icon: L.divIcon({
|
||
className: 'sat-marker',
|
||
html: '<div style="background:#ffff00;width:12px;height:12px;border-radius:50%;border:2px solid #000;box-shadow:0 0 10px #ffff00;"></div>',
|
||
iconSize: [12, 12],
|
||
iconAnchor: [6, 6]
|
||
})
|
||
}).addTo(groundTrackMap).bindPopup(pass.satellite);
|
||
}
|
||
|
||
// Update observer marker position
|
||
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
|
||
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
|
||
if (observerMarker) {
|
||
observerMarker.setLatLng([lat, lon]);
|
||
}
|
||
|
||
// Fit bounds to show track
|
||
if (allCoords.length > 0) {
|
||
groundTrackMap.fitBounds(L.latLngBounds(allCoords), { padding: [20, 20] });
|
||
}
|
||
}
|
||
|
||
function toggleGroundTrack() {
|
||
const show = document.getElementById('showGroundTrack').checked;
|
||
document.getElementById('groundTrackMap').style.display = show ? 'block' : 'none';
|
||
if (show && groundTrackMap) {
|
||
groundTrackMap.invalidateSize();
|
||
}
|
||
}
|
||
|
||
function startSatellitePositionUpdates() {
|
||
if (satPositionInterval) clearInterval(satPositionInterval);
|
||
satPositionInterval = setInterval(() => {
|
||
if (selectedPass) {
|
||
updateRealTimePosition();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function updateRealTimePosition() {
|
||
let satellites = getSelectedSatellites();
|
||
|
||
// Ensure selected pass's satellite is included in the request
|
||
if (selectedPass && selectedPass.satellite) {
|
||
if (!satellites.includes(selectedPass.satellite)) {
|
||
satellites = [selectedPass.satellite, ...satellites];
|
||
}
|
||
}
|
||
|
||
if (satellites.length === 0) return;
|
||
|
||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||
|
||
fetch('/satellite/position', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ lat, lon, satellites, includeTrack: true })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.positions) {
|
||
updateRealTimeIndicators(data.positions);
|
||
}
|
||
});
|
||
}
|
||
|
||
let orbitTrackLine = null;
|
||
let pastOrbitLine = null;
|
||
|
||
function updateRealTimeIndicators(positions) {
|
||
// Update ground track map markers
|
||
positions.forEach(pos => {
|
||
if (selectedPass && pos.satellite === selectedPass.satellite) {
|
||
// Update satellite marker position
|
||
if (satMarker) {
|
||
satMarker.setLatLng([pos.lat, pos.lon]);
|
||
satMarker.setPopupContent(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km<br>El: ' + pos.elevation.toFixed(1) + '°');
|
||
} else if (groundTrackMap) {
|
||
satMarker = L.marker([pos.lat, pos.lon], {
|
||
icon: L.divIcon({
|
||
className: 'sat-marker',
|
||
html: '<div style="background:#ffff00;width:14px;height:14px;border-radius:50%;border:2px solid #000;box-shadow:0 0 15px #ffff00;animation:pulse-sat 1s infinite;"></div>',
|
||
iconSize: [14, 14],
|
||
iconAnchor: [7, 7]
|
||
})
|
||
}).addTo(groundTrackMap).bindPopup(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km');
|
||
}
|
||
|
||
// Draw full orbit track from position endpoint
|
||
// Backend returns 'track' property
|
||
const orbitData = pos.track || pos.orbitTrack;
|
||
if (orbitData && orbitData.length > 0 && groundTrackMap) {
|
||
// Split into past and future, handling antimeridian crossings
|
||
const pastPoints = orbitData.filter(p => p.past);
|
||
const futurePoints = orbitData.filter(p => !p.past);
|
||
|
||
// Helper to split coords only at true antimeridian crossings (±180° line)
|
||
function splitAtAntimeridian(points) {
|
||
const segments = [];
|
||
let currentSegment = [];
|
||
for (let i = 0; i < points.length; i++) {
|
||
const p = points[i];
|
||
if (currentSegment.length > 0) {
|
||
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||
// Only split when crossing the antimeridian (one side > 90, other < -90)
|
||
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
|
||
if (crossesAntimeridian) {
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
currentSegment = [];
|
||
}
|
||
}
|
||
currentSegment.push([p.lat, p.lon]);
|
||
}
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
return segments;
|
||
}
|
||
|
||
// Remove old lines
|
||
if (orbitTrackLine) groundTrackMap.removeLayer(orbitTrackLine);
|
||
if (pastOrbitLine) groundTrackMap.removeLayer(pastOrbitLine);
|
||
|
||
// Draw past track segments (dimmer)
|
||
const pastSegments = splitAtAntimeridian(pastPoints);
|
||
if (pastSegments.length > 0) {
|
||
pastOrbitLine = L.layerGroup();
|
||
pastSegments.forEach(seg => {
|
||
L.polyline(seg, {
|
||
color: '#666666',
|
||
weight: 2,
|
||
opacity: 0.5,
|
||
dashArray: '3, 6'
|
||
}).addTo(pastOrbitLine);
|
||
});
|
||
pastOrbitLine.addTo(groundTrackMap);
|
||
}
|
||
|
||
// Draw future track segments (brighter)
|
||
const futureSegments = splitAtAntimeridian(futurePoints);
|
||
if (futureSegments.length > 0) {
|
||
orbitTrackLine = L.layerGroup();
|
||
futureSegments.forEach(seg => {
|
||
L.polyline(seg, {
|
||
color: selectedPass.color || '#00ff00',
|
||
weight: 3,
|
||
opacity: 0.8
|
||
}).addTo(orbitTrackLine);
|
||
});
|
||
orbitTrackLine.addTo(groundTrackMap);
|
||
}
|
||
}
|
||
|
||
// Update polar plot with pass trajectory and real-time position
|
||
if (selectedPass) {
|
||
drawPolarPlot(selectedPass);
|
||
// Draw current position on top if satellite is visible
|
||
if (pos.elevation > 0) {
|
||
drawRealTimePositionOnPolar(pos);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function drawRealTimePositionOnPolar(pos) {
|
||
const canvas = document.getElementById('polarPlotCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const size = canvas.width;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = size / 2 - 30;
|
||
|
||
// Draw pulsing indicator for current position
|
||
const r = radius * (90 - pos.elevation) / 90;
|
||
const rad = pos.azimuth * Math.PI / 180;
|
||
const x = cx + Math.sin(rad) * r;
|
||
const y = cy - Math.cos(rad) * r;
|
||
|
||
ctx.fillStyle = '#ffff00';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.strokeStyle = '#ffff00';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 12, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function updateTLE() {
|
||
fetch('/satellite/update-tle', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
showInfo('TLE data updated!');
|
||
} else {
|
||
alert('Error updating TLE: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Satellite management
|
||
let trackedSatellites = [
|
||
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
|
||
{ id: 'NOAA-15', name: 'NOAA 15', norad: '25338', builtin: true, checked: true },
|
||
{ id: 'NOAA-18', name: 'NOAA 18', norad: '28654', builtin: true, checked: true },
|
||
{ id: 'NOAA-19', name: 'NOAA 19', norad: '33591', builtin: true, checked: true },
|
||
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
|
||
];
|
||
|
||
function renderSatelliteList() {
|
||
const list = document.getElementById('satelliteList');
|
||
if (!list) return;
|
||
|
||
list.innerHTML = trackedSatellites.map((sat, idx) => `
|
||
<div class="sat-item ${sat.builtin ? 'builtin' : ''}">
|
||
<label>
|
||
<input type="checkbox" ${sat.checked ? 'checked' : ''} onchange="toggleSatellite(${idx})">
|
||
<span class="sat-name">${sat.name}</span>
|
||
<span class="sat-norad">#${sat.norad}</span>
|
||
</label>
|
||
<button class="sat-remove" onclick="removeSatellite(${idx})" title="Remove">✕</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function toggleSatellite(idx) {
|
||
trackedSatellites[idx].checked = !trackedSatellites[idx].checked;
|
||
}
|
||
|
||
function removeSatellite(idx) {
|
||
if (!trackedSatellites[idx].builtin) {
|
||
trackedSatellites.splice(idx, 1);
|
||
renderSatelliteList();
|
||
}
|
||
}
|
||
|
||
function getSelectedSatellites() {
|
||
return trackedSatellites.filter(s => s.checked).map(s => s.id);
|
||
}
|
||
|
||
function showAddSatelliteModal() {
|
||
document.getElementById('satModal').classList.add('active');
|
||
}
|
||
|
||
function closeSatModal() {
|
||
document.getElementById('satModal').classList.remove('active');
|
||
}
|
||
|
||
function switchSatModalTab(tab) {
|
||
document.querySelectorAll('.sat-modal-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.sat-modal-section').forEach(s => s.classList.remove('active'));
|
||
|
||
if (tab === 'tle') {
|
||
document.querySelector('.sat-modal-tab:first-child').classList.add('active');
|
||
document.getElementById('tleSection').classList.add('active');
|
||
} else {
|
||
document.querySelector('.sat-modal-tab:last-child').classList.add('active');
|
||
document.getElementById('celestrakSection').classList.add('active');
|
||
}
|
||
}
|
||
|
||
function addFromTLE() {
|
||
const tleText = document.getElementById('tleInput').value.trim();
|
||
if (!tleText) {
|
||
alert('Please paste TLE data');
|
||
return;
|
||
}
|
||
|
||
const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l);
|
||
let added = 0;
|
||
|
||
for (let i = 0; i < lines.length; i += 3) {
|
||
if (i + 2 < lines.length) {
|
||
const name = lines[i];
|
||
const line1 = lines[i + 1];
|
||
const line2 = lines[i + 2];
|
||
|
||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||
const norad = line1.substring(2, 7).trim();
|
||
const id = name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase();
|
||
|
||
// Check if already exists
|
||
if (!trackedSatellites.find(s => s.norad === norad)) {
|
||
trackedSatellites.push({
|
||
id: id,
|
||
name: name,
|
||
norad: norad,
|
||
builtin: false,
|
||
checked: true,
|
||
tle: [name, line1, line2]
|
||
});
|
||
added++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (added > 0) {
|
||
renderSatelliteList();
|
||
document.getElementById('tleInput').value = '';
|
||
closeSatModal();
|
||
showInfo(`Added ${added} satellite(s)`);
|
||
} else {
|
||
alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)');
|
||
}
|
||
}
|
||
|
||
function fetchCelestrak() {
|
||
showAddSatelliteModal();
|
||
switchSatModalTab('celestrak');
|
||
}
|
||
|
||
function fetchCelestrakCategory(category) {
|
||
const status = document.getElementById('celestrakStatus');
|
||
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
|
||
|
||
fetch('/satellite/celestrak/' + category)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.satellites) {
|
||
let added = 0;
|
||
data.satellites.forEach(sat => {
|
||
if (!trackedSatellites.find(s => s.norad === sat.norad)) {
|
||
trackedSatellites.push({
|
||
id: sat.id,
|
||
name: sat.name,
|
||
norad: sat.norad,
|
||
builtin: false,
|
||
checked: false, // Don't auto-select
|
||
tle: sat.tle
|
||
});
|
||
added++;
|
||
}
|
||
});
|
||
renderSatelliteList();
|
||
status.innerHTML = `<span style="color: var(--accent-green);">Added ${added} satellites (${data.satellites.length} total in category)</span>`;
|
||
} else {
|
||
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
|
||
}
|
||
})
|
||
.catch(err => {
|
||
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`;
|
||
});
|
||
}
|
||
|
||
// Initialize satellite list when satellite mode is loaded
|
||
function initSatelliteList() {
|
||
renderSatelliteList();
|
||
}
|
||
|
||
// Utility function
|
||
function showInfo(message) {
|
||
// Simple notification - could be enhanced
|
||
const existing = document.querySelector('.info-toast');
|
||
if (existing) existing.remove();
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = 'info-toast';
|
||
toast.textContent = message;
|
||
toast.style.cssText = 'position: fixed; bottom: 20px; right: 20px; background: var(--accent-cyan); color: #000; padding: 10px 20px; border-radius: 4px; z-index: 10001; font-size: 12px;';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 3000);
|
||
}
|
||
|
||
// Theme toggle functions
|
||
function toggleTheme() {
|
||
const html = document.documentElement;
|
||
const currentTheme = html.getAttribute('data-theme');
|
||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||
|
||
if (newTheme === 'dark') {
|
||
html.removeAttribute('data-theme');
|
||
} else {
|
||
html.setAttribute('data-theme', newTheme);
|
||
}
|
||
|
||
// Save to localStorage for instant load on next visit
|
||
localStorage.setItem('intercept-theme', newTheme);
|
||
|
||
// Persist to server for cross-device sync
|
||
fetch('/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ theme: newTheme })
|
||
}).catch(err => console.warn('Failed to save theme to server:', err));
|
||
}
|
||
|
||
// Load saved theme on page load
|
||
(function () {
|
||
// First apply localStorage theme for instant load (no flash)
|
||
const localTheme = localStorage.getItem('intercept-theme');
|
||
if (localTheme === 'light') {
|
||
document.documentElement.setAttribute('data-theme', 'light');
|
||
}
|
||
|
||
// Then fetch from server to sync (in case changed on another device)
|
||
fetch('/settings/theme')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.value) {
|
||
const serverTheme = data.value;
|
||
if (serverTheme !== localTheme) {
|
||
// Server has different theme, apply it
|
||
if (serverTheme === 'light') {
|
||
document.documentElement.setAttribute('data-theme', 'light');
|
||
} else {
|
||
document.documentElement.removeAttribute('data-theme');
|
||
}
|
||
localStorage.setItem('intercept-theme', serverTheme);
|
||
}
|
||
}
|
||
})
|
||
.catch(() => { }); // Ignore errors, localStorage is fallback
|
||
})();
|
||
|
||
// Help modal functions
|
||
function showHelp() {
|
||
document.getElementById('helpModal').classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function hideHelp() {
|
||
document.getElementById('helpModal').classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function switchHelpTab(tab) {
|
||
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
|
||
document.querySelector(`.help-tab[data-tab="${tab}"]`).classList.add('active');
|
||
document.getElementById(`help-${tab}`).classList.add('active');
|
||
}
|
||
|
||
// Keyboard shortcuts for help
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') hideHelp();
|
||
// Open help with F1 or ? key (when not typing in an input)
|
||
if ((e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !document.getElementById('helpModal').classList.contains('active')) {
|
||
e.preventDefault();
|
||
showHelp();
|
||
}
|
||
});
|
||
|
||
// NOTE: Scanner and Audio Receiver code moved to static/js/modes/listening-post.js
|
||
|
||
// ============================================
|
||
// TSCM (Counter-Surveillance) Functions
|
||
// ============================================
|
||
let isTscmRunning = false;
|
||
let tscmEventSource = null;
|
||
let tscmThreats = [];
|
||
let tscmWifiDevices = [];
|
||
let tscmBtDevices = [];
|
||
let isRecordingBaseline = false;
|
||
let tscmSweepStartTime = null;
|
||
let tscmSweepEndTime = null;
|
||
|
||
async function refreshTscmDevices() {
|
||
// Fetch available interfaces for TSCM scanning
|
||
try {
|
||
const response = await fetch('/tscm/devices');
|
||
const data = await response.json();
|
||
const devices = data.devices || {};
|
||
|
||
// Populate WiFi interfaces
|
||
const wifiSelect = document.getElementById('tscmWifiInterface');
|
||
wifiSelect.innerHTML = '<option value="">Select WiFi interface...</option>';
|
||
if (devices.wifi_interfaces && devices.wifi_interfaces.length > 0) {
|
||
devices.wifi_interfaces.forEach(iface => {
|
||
const opt = document.createElement('option');
|
||
opt.value = iface.name;
|
||
opt.textContent = iface.display_name || iface.name;
|
||
wifiSelect.appendChild(opt);
|
||
});
|
||
// Auto-select first interface
|
||
if (devices.wifi_interfaces.length > 0) {
|
||
wifiSelect.value = devices.wifi_interfaces[0].name;
|
||
}
|
||
} else {
|
||
wifiSelect.innerHTML = '<option value="">No WiFi interfaces found</option>';
|
||
}
|
||
|
||
// Populate Bluetooth adapters
|
||
const btSelect = document.getElementById('tscmBtInterface');
|
||
btSelect.innerHTML = '<option value="">Select Bluetooth adapter...</option>';
|
||
if (devices.bt_adapters && devices.bt_adapters.length > 0) {
|
||
devices.bt_adapters.forEach(adapter => {
|
||
const opt = document.createElement('option');
|
||
opt.value = adapter.name;
|
||
opt.textContent = adapter.display_name || adapter.name;
|
||
btSelect.appendChild(opt);
|
||
});
|
||
// Auto-select first adapter
|
||
if (devices.bt_adapters.length > 0) {
|
||
btSelect.value = devices.bt_adapters[0].name;
|
||
}
|
||
} else {
|
||
btSelect.innerHTML = '<option value="">No Bluetooth adapters found</option>';
|
||
}
|
||
|
||
// Populate SDR devices
|
||
const sdrSelect = document.getElementById('tscmSdrDevice');
|
||
sdrSelect.innerHTML = '<option value="">Select SDR device...</option>';
|
||
if (devices.sdr_devices && devices.sdr_devices.length > 0) {
|
||
devices.sdr_devices.forEach(dev => {
|
||
const opt = document.createElement('option');
|
||
opt.value = dev.index;
|
||
opt.textContent = dev.display_name || dev.name || 'SDR Device';
|
||
sdrSelect.appendChild(opt);
|
||
});
|
||
// Auto-select first SDR if available
|
||
if (devices.sdr_devices.length > 0) {
|
||
sdrSelect.value = devices.sdr_devices[0].index;
|
||
}
|
||
} else {
|
||
sdrSelect.innerHTML = '<option value="">No SDR devices found</option>';
|
||
}
|
||
|
||
// Show warnings (e.g., not running as root)
|
||
const warningsDiv = document.getElementById('tscmDeviceWarnings');
|
||
if (data.warnings && data.warnings.length > 0) {
|
||
warningsDiv.innerHTML = data.warnings.map(w =>
|
||
`<div class="tscm-privilege-warning">
|
||
<span class="warning-icon">⚠️</span>
|
||
<div>
|
||
<strong>${escapeHtml(w.message)}</strong>
|
||
${w.action ? `<div class="warning-action">${escapeHtml(w.action)}</div>` : ''}
|
||
</div>
|
||
</div>`
|
||
).join('');
|
||
warningsDiv.style.display = 'block';
|
||
} else {
|
||
warningsDiv.style.display = 'none';
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error('Failed to refresh TSCM devices:', e);
|
||
}
|
||
}
|
||
|
||
async function loadTscmBaselines() {
|
||
try {
|
||
const response = await fetch('/tscm/baselines');
|
||
const data = await response.json();
|
||
const select = document.getElementById('tscmBaselineSelect');
|
||
select.innerHTML = '<option value="">No Baseline</option>';
|
||
if (data.baselines) {
|
||
data.baselines.forEach(b => {
|
||
const opt = document.createElement('option');
|
||
opt.value = b.id;
|
||
opt.textContent = b.name + (b.is_active ? ' (Active)' : '');
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load baselines:', e);
|
||
}
|
||
}
|
||
|
||
async function startTscmSweep() {
|
||
const sweepType = document.getElementById('tscmSweepType').value;
|
||
const baselineId = document.getElementById('tscmBaselineSelect').value || null;
|
||
const wifiEnabled = document.getElementById('tscmWifiEnabled').checked;
|
||
const btEnabled = document.getElementById('tscmBtEnabled').checked;
|
||
const rfEnabled = document.getElementById('tscmRfEnabled').checked;
|
||
const wifiInterface = document.getElementById('tscmWifiInterface').value;
|
||
const btInterface = document.getElementById('tscmBtInterface').value;
|
||
const sdrDevice = document.getElementById('tscmSdrDevice').value;
|
||
|
||
// Clear any previous warnings
|
||
document.getElementById('tscmDeviceWarnings').style.display = 'none';
|
||
document.getElementById('tscmDeviceWarnings').innerHTML = '';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/sweep/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
sweep_type: sweepType,
|
||
baseline_id: baselineId ? parseInt(baselineId) : null,
|
||
wifi: wifiEnabled,
|
||
bluetooth: btEnabled,
|
||
rf: rfEnabled,
|
||
wifi_interface: wifiInterface,
|
||
bt_interface: btInterface,
|
||
sdr_device: sdrDevice ? parseInt(sdrDevice) : null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
isTscmRunning = true;
|
||
tscmSweepStartTime = new Date();
|
||
tscmSweepEndTime = null;
|
||
document.getElementById('startTscmBtn').style.display = 'none';
|
||
document.getElementById('stopTscmBtn').style.display = 'block';
|
||
document.getElementById('tscmProgress').style.display = 'flex';
|
||
document.getElementById('tscmReportBtn').style.display = 'none';
|
||
|
||
// Show warnings if any devices unavailable
|
||
if (data.warnings && data.warnings.length > 0) {
|
||
const warningsDiv = document.getElementById('tscmDeviceWarnings');
|
||
warningsDiv.innerHTML = data.warnings.map(w =>
|
||
`<div style="color: #ff9933; font-size: 10px; margin-bottom: 2px;">⚠ ${w}</div>`
|
||
).join('');
|
||
warningsDiv.style.display = 'block';
|
||
}
|
||
|
||
// Update device indicators
|
||
updateTscmDeviceIndicators(data.devices);
|
||
|
||
// Reset displays
|
||
tscmThreats = [];
|
||
tscmWifiDevices = [];
|
||
tscmBtDevices = [];
|
||
tscmRfSignals = [];
|
||
tscmRfStatusMessage = null;
|
||
updateTscmDisplays();
|
||
|
||
// Start SSE stream
|
||
startTscmStream();
|
||
} else {
|
||
// Show error with details
|
||
let errorMsg = data.message || 'Failed to start sweep';
|
||
if (data.details && data.details.length > 0) {
|
||
errorMsg += '\n\n' + data.details.join('\n');
|
||
}
|
||
alert(errorMsg);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to start TSCM sweep:', e);
|
||
alert('Failed to start sweep: Network error');
|
||
}
|
||
}
|
||
|
||
function updateTscmDeviceIndicators(devices) {
|
||
const wifiIndicator = document.getElementById('tscmWifiIndicator');
|
||
const btIndicator = document.getElementById('tscmBtIndicator');
|
||
const rfIndicator = document.getElementById('tscmRfIndicator');
|
||
|
||
if (wifiIndicator) {
|
||
wifiIndicator.classList.toggle('active', devices.wifi);
|
||
wifiIndicator.classList.toggle('inactive', !devices.wifi);
|
||
}
|
||
if (btIndicator) {
|
||
btIndicator.classList.toggle('active', devices.bluetooth);
|
||
btIndicator.classList.toggle('inactive', !devices.bluetooth);
|
||
}
|
||
if (rfIndicator) {
|
||
rfIndicator.classList.toggle('active', devices.rf);
|
||
rfIndicator.classList.toggle('inactive', !devices.rf);
|
||
}
|
||
}
|
||
|
||
async function stopTscmSweep() {
|
||
try {
|
||
await fetch('/tscm/sweep/stop', { method: 'POST' });
|
||
} catch (e) {
|
||
console.error('Error stopping sweep:', e);
|
||
}
|
||
|
||
isTscmRunning = false;
|
||
tscmSweepEndTime = new Date();
|
||
if (tscmEventSource) {
|
||
tscmEventSource.close();
|
||
tscmEventSource = null;
|
||
}
|
||
|
||
document.getElementById('startTscmBtn').style.display = 'block';
|
||
document.getElementById('stopTscmBtn').style.display = 'none';
|
||
document.getElementById('tscmProgress').style.display = 'none';
|
||
|
||
// Show report button if we have any data
|
||
const hasData = tscmWifiDevices.length > 0 || tscmBtDevices.length > 0 || tscmRfSignals.length > 0;
|
||
document.getElementById('tscmReportBtn').style.display = hasData ? 'block' : 'none';
|
||
}
|
||
|
||
function generateTscmReport() {
|
||
// Calculate sweep duration
|
||
const startTime = tscmSweepStartTime || new Date();
|
||
const endTime = tscmSweepEndTime || new Date();
|
||
const durationMs = endTime - startTime;
|
||
const durationMin = Math.floor(durationMs / 60000);
|
||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||
|
||
// Categorize devices by classification
|
||
const allDevices = [
|
||
...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi' })),
|
||
...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth' })),
|
||
...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' }))
|
||
];
|
||
|
||
const highInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6);
|
||
const needsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6));
|
||
const informational = allDevices.filter(d => d.classification === 'informational' || d.score < 3);
|
||
|
||
// Determine overall assessment
|
||
let assessment = 'LOW CONCERN';
|
||
let assessmentClass = 'informational';
|
||
if (highInterest.length > 0) {
|
||
assessment = `ELEVATED CONCERN: ${highInterest.length} high-interest item(s) detected requiring immediate attention`;
|
||
assessmentClass = 'high-interest';
|
||
} else if (needsReview.length > 0) {
|
||
assessment = `MODERATE CONCERN: ${needsReview.length} item(s) requiring further review`;
|
||
assessmentClass = 'needs-review';
|
||
} else {
|
||
assessment = 'LOW CONCERN: No significant threats detected. Environment appears normal.';
|
||
}
|
||
|
||
// Helper function to render device row
|
||
const renderDevice = (device) => {
|
||
const scoreClass = device.score >= 6 ? 'high' : (device.score >= 3 ? 'medium' : 'low');
|
||
const indicators = (device.indicators || []).map(i =>
|
||
`<span class="indicator">${i.type}: ${i.desc}</span>`
|
||
).join('');
|
||
const reasons = (device.reasons || []).map(r => `<li>${r}</li>`).join('');
|
||
|
||
let identifier = device.bssid || device.mac || (device.frequency ? `${device.frequency} MHz` : 'Unknown');
|
||
let name = device.essid || device.name || device.band || 'Unknown';
|
||
|
||
return `
|
||
<tr class="device-row ${device.classification || ''}">
|
||
<td><span class="protocol-badge ${device.protocol.toLowerCase()}">${device.protocol}</span></td>
|
||
<td><strong>${name}</strong><br><small class="identifier">${identifier}</small></td>
|
||
<td><span class="score-badge ${scoreClass}">${device.score || 0}</span></td>
|
||
<td>${device.classification || 'unknown'}</td>
|
||
<td>${device.signal || device.rssi || device.power || 'N/A'} dBm</td>
|
||
<td>
|
||
${indicators ? `<div class="indicators">${indicators}</div>` : ''}
|
||
${reasons ? `<ul class="reasons">${reasons}</ul>` : ''}
|
||
</td>
|
||
<td>${device.recommended_action || 'monitor'}</td>
|
||
</tr>
|
||
`;
|
||
};
|
||
|
||
// Generate HTML report
|
||
const reportHtml = `
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>TSCM Sweep Report - ${startTime.toLocaleDateString()}</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: #1a1a2e;
|
||
color: #e8eaed;
|
||
padding: 40px;
|
||
line-height: 1.6;
|
||
}
|
||
.report-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
background: #0f1218;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||
}
|
||
.report-header {
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #0f1218 100%);
|
||
padding: 40px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
.report-title {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #4a9eff;
|
||
margin-bottom: 8px;
|
||
}
|
||
.report-subtitle {
|
||
font-size: 14px;
|
||
color: #9ca3af;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
}
|
||
.report-meta {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
margin-top: 30px;
|
||
padding: 20px;
|
||
background: rgba(0,0,0,0.3);
|
||
border-radius: 8px;
|
||
}
|
||
.meta-item {
|
||
text-align: center;
|
||
}
|
||
.meta-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
.meta-label {
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
.section {
|
||
padding: 30px 40px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
.section:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #4a9eff;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.assessment {
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
.assessment.high-interest {
|
||
background: rgba(255, 51, 51, 0.15);
|
||
border: 1px solid #ff3333;
|
||
color: #ff6666;
|
||
}
|
||
.assessment.needs-review {
|
||
background: rgba(255, 204, 0, 0.15);
|
||
border: 1px solid #ffcc00;
|
||
color: #ffdd44;
|
||
}
|
||
.assessment.informational {
|
||
background: rgba(0, 204, 0, 0.15);
|
||
border: 1px solid #00cc00;
|
||
color: #44dd44;
|
||
}
|
||
.summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.summary-card {
|
||
background: rgba(0,0,0,0.3);
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
border: 1px solid #2a2a4a;
|
||
}
|
||
.summary-card.high-interest { border-color: #ff3333; }
|
||
.summary-card.needs-review { border-color: #ffcc00; }
|
||
.summary-card.informational { border-color: #00cc00; }
|
||
.summary-card .count {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
}
|
||
.summary-card.high-interest .count { color: #ff3333; }
|
||
.summary-card.needs-review .count { color: #ffcc00; }
|
||
.summary-card.informational .count { color: #00cc00; }
|
||
.summary-card .label {
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
margin-top: 4px;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th {
|
||
text-align: left;
|
||
padding: 12px;
|
||
background: rgba(0,0,0,0.4);
|
||
color: #9ca3af;
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
td {
|
||
padding: 12px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
vertical-align: top;
|
||
}
|
||
.device-row.high_interest { background: rgba(255, 51, 51, 0.08); }
|
||
.device-row.review { background: rgba(255, 204, 0, 0.08); }
|
||
.protocol-badge {
|
||
display: inline-block;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
}
|
||
.protocol-badge.wifi { background: #4a9eff; color: #000; }
|
||
.protocol-badge.bluetooth { background: #8b5cf6; color: #fff; }
|
||
.protocol-badge.rf { background: #f59e0b; color: #000; }
|
||
.score-badge {
|
||
display: inline-block;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
.score-badge.high { background: rgba(255,51,51,0.2); color: #ff3333; }
|
||
.score-badge.medium { background: rgba(255,204,0,0.2); color: #ffcc00; }
|
||
.score-badge.low { background: rgba(0,204,0,0.2); color: #00cc00; }
|
||
.identifier {
|
||
color: #6b7280;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
}
|
||
.indicators {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.indicator {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
background: rgba(255,153,51,0.2);
|
||
color: #ff9933;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
}
|
||
.reasons {
|
||
margin: 0;
|
||
padding-left: 16px;
|
||
font-size: 11px;
|
||
color: #9ca3af;
|
||
}
|
||
.reasons li {
|
||
margin-bottom: 2px;
|
||
}
|
||
.category-section {
|
||
margin-bottom: 30px;
|
||
}
|
||
.category-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
}
|
||
.category-title.high-interest { background: rgba(255,51,51,0.15); color: #ff6666; }
|
||
.category-title.needs-review { background: rgba(255,204,0,0.15); color: #ffdd44; }
|
||
.category-title.informational { background: rgba(0,204,0,0.15); color: #44dd44; }
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #6b7280;
|
||
}
|
||
.disclaimer {
|
||
padding: 20px;
|
||
background: rgba(74, 158, 255, 0.1);
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
}
|
||
.disclaimer h4 {
|
||
color: #4a9eff;
|
||
margin-bottom: 10px;
|
||
font-size: 13px;
|
||
}
|
||
.recommendations {
|
||
margin-top: 20px;
|
||
}
|
||
.recommendations ul {
|
||
padding-left: 20px;
|
||
}
|
||
.recommendations li {
|
||
margin-bottom: 8px;
|
||
}
|
||
.report-actions {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
display: flex;
|
||
gap: 10px;
|
||
z-index: 1000;
|
||
}
|
||
.report-btn {
|
||
padding: 12px 24px;
|
||
background: #4a9eff;
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
.report-btn:hover {
|
||
background: #6bb3ff;
|
||
}
|
||
.report-btn.save {
|
||
background: #22c55e;
|
||
}
|
||
.report-btn.save:hover {
|
||
background: #2ecc71;
|
||
}
|
||
@media print {
|
||
body { background: #fff; color: #000; padding: 20px; }
|
||
.report-container { box-shadow: none; }
|
||
.report-header { background: #f8f9fa; }
|
||
.report-title { color: #1a1a2e; }
|
||
.section { border-color: #ddd; }
|
||
.section-title { color: #1a1a2e; }
|
||
th { background: #f0f0f0; color: #333; }
|
||
td { border-color: #ddd; }
|
||
.report-actions { display: none; }
|
||
.device-row.high_interest { background: rgba(255, 51, 51, 0.1); }
|
||
.device-row.review { background: rgba(255, 204, 0, 0.1); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="report-actions">
|
||
<button class="report-btn save" onclick="saveReport()">💾 Save Report</button>
|
||
<button class="report-btn" onclick="window.print()">🖨️ Print Report</button>
|
||
</div>
|
||
|
||
<div class="report-container">
|
||
<div class="report-header">
|
||
<div class="report-title">TSCM Sweep Report</div>
|
||
<div class="report-subtitle">Technical Surveillance Counter-Measures Analysis</div>
|
||
|
||
<div class="report-meta">
|
||
<div class="meta-item">
|
||
<div class="meta-value">${startTime.toLocaleDateString()}</div>
|
||
<div class="meta-label">Date</div>
|
||
</div>
|
||
<div class="meta-item">
|
||
<div class="meta-value">${startTime.toLocaleTimeString()} - ${endTime.toLocaleTimeString()}</div>
|
||
<div class="meta-label">Time Range</div>
|
||
</div>
|
||
<div class="meta-item">
|
||
<div class="meta-value">${durationMin}m ${durationSec}s</div>
|
||
<div class="meta-label">Duration</div>
|
||
</div>
|
||
<div class="meta-item">
|
||
<div class="meta-value">${allDevices.length}</div>
|
||
<div class="meta-label">Total Devices</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">📊 Executive Summary</div>
|
||
<div class="summary-grid">
|
||
<div class="summary-card high-interest">
|
||
<div class="count">${highInterest.length}</div>
|
||
<div class="label">High Interest</div>
|
||
</div>
|
||
<div class="summary-card needs-review">
|
||
<div class="count">${needsReview.length}</div>
|
||
<div class="label">Needs Review</div>
|
||
</div>
|
||
<div class="summary-card informational">
|
||
<div class="count">${informational.length}</div>
|
||
<div class="label">Informational</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="count" style="color: #4a9eff;">${tscmWifiDevices.length}/${tscmBtDevices.length}/${tscmRfSignals.length}</div>
|
||
<div class="label">WiFi/BT/RF</div>
|
||
</div>
|
||
</div>
|
||
<div class="assessment ${assessmentClass}">
|
||
<strong>Assessment:</strong> ${assessment}
|
||
</div>
|
||
</div>
|
||
|
||
${highInterest.length > 0 ? `
|
||
<div class="section">
|
||
<div class="section-title">🔴 High Interest Items</div>
|
||
<div class="category-section">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Device</th>
|
||
<th>Score</th>
|
||
<th>Class</th>
|
||
<th>Signal</th>
|
||
<th>Indicators / Reasons</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${highInterest.map(renderDevice).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${needsReview.length > 0 ? `
|
||
<div class="section">
|
||
<div class="section-title">🟡 Items Requiring Review</div>
|
||
<div class="category-section">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Device</th>
|
||
<th>Score</th>
|
||
<th>Class</th>
|
||
<th>Signal</th>
|
||
<th>Indicators / Reasons</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${needsReview.map(renderDevice).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${informational.length > 0 ? `
|
||
<div class="section">
|
||
<div class="section-title">🟢 Informational Items</div>
|
||
<div class="category-section">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Device</th>
|
||
<th>Score</th>
|
||
<th>Class</th>
|
||
<th>Signal</th>
|
||
<th>Indicators / Reasons</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${informational.map(renderDevice).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${allDevices.length === 0 ? `
|
||
<div class="section">
|
||
<div class="empty-state">
|
||
<p>No devices were detected during this sweep.</p>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="section">
|
||
<div class="section-title">📋 Recommendations</div>
|
||
<div class="recommendations">
|
||
<ul>
|
||
${highInterest.length > 0 ? `
|
||
<li><strong>Immediate Action Required:</strong> ${highInterest.length} high-interest item(s) detected. These devices exhibit characteristics commonly associated with surveillance equipment and should be physically located and investigated.</li>
|
||
` : ''}
|
||
${needsReview.length > 0 ? `
|
||
<li><strong>Further Investigation Recommended:</strong> ${needsReview.length} item(s) require additional review to determine their purpose and legitimacy.</li>
|
||
` : ''}
|
||
${allDevices.filter(d => d.is_new).length > 0 ? `
|
||
<li><strong>New Devices Detected:</strong> ${allDevices.filter(d => d.is_new).length} device(s) were not present in the baseline. Verify these are authorized.</li>
|
||
` : ''}
|
||
<li><strong>Regular Monitoring:</strong> Consider establishing a baseline of normal wireless activity and conducting periodic sweeps to detect changes.</li>
|
||
<li><strong>Physical Inspection:</strong> For any high-interest items, conduct a thorough physical inspection of the area to locate potential surveillance devices.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">⚠️ Disclaimer</div>
|
||
<div class="disclaimer">
|
||
<h4>Important Notice</h4>
|
||
<p>This report is generated by automated wireless spectrum analysis software. The findings presented are <strong>indicators only</strong> and do not constitute confirmation of surveillance activity. Many legitimate devices may trigger alerts due to their wireless characteristics.</p>
|
||
<p style="margin-top: 10px;">Professional TSCM services involve specialized equipment and expertise beyond wireless spectrum analysis, including: non-linear junction detection, thermal imaging, physical inspection, and RF spectrum analysis with calibrated equipment.</p>
|
||
<p style="margin-top: 10px;"><strong>No content was intercepted or decoded during this analysis.</strong> This tool only detects the presence and characteristics of wireless transmissions.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<scr` + `ipt>
|
||
function saveReport() {
|
||
const html = document.documentElement.outerHTML;
|
||
const blob = new Blob([html], { type: 'text/html' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'TSCM_Report_${startTime.toISOString().split('T')[0]}.html';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
</scr` + `ipt>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
// Open report in new window
|
||
const reportWindow = window.open('', '_blank');
|
||
reportWindow.document.write(reportHtml);
|
||
reportWindow.document.close();
|
||
}
|
||
|
||
function startTscmStream() {
|
||
if (tscmEventSource) {
|
||
tscmEventSource.close();
|
||
}
|
||
|
||
tscmEventSource = new EventSource('/tscm/sweep/stream');
|
||
|
||
tscmEventSource.onmessage = function (event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
handleTscmEvent(data);
|
||
} catch (e) {
|
||
console.error('TSCM SSE parse error:', e);
|
||
}
|
||
};
|
||
|
||
tscmEventSource.onerror = function () {
|
||
console.warn('TSCM SSE connection error');
|
||
};
|
||
}
|
||
|
||
let tscmCorrelations = [];
|
||
|
||
function handleTscmEvent(data) {
|
||
switch (data.type) {
|
||
case 'sweep_progress':
|
||
updateTscmProgress(data);
|
||
break;
|
||
case 'wifi_device':
|
||
addTscmWifiDevice(data);
|
||
break;
|
||
case 'bt_device':
|
||
addTscmBtDevice(data);
|
||
break;
|
||
case 'rf_signal':
|
||
addTscmRfSignal(data);
|
||
break;
|
||
case 'rf_status':
|
||
handleRfStatus(data);
|
||
break;
|
||
case 'threat_detected':
|
||
addTscmThreat(data);
|
||
break;
|
||
case 'correlation_findings':
|
||
handleCorrelationFindings(data);
|
||
break;
|
||
case 'sweep_completed':
|
||
completeTscmSweep(data);
|
||
break;
|
||
case 'sweep_stopped':
|
||
case 'sweep_error':
|
||
stopTscmSweep();
|
||
break;
|
||
}
|
||
}
|
||
|
||
function handleCorrelationFindings(data) {
|
||
tscmCorrelations = data.correlations || [];
|
||
updateCorrelationsDisplay();
|
||
}
|
||
|
||
function addTscmWifiDevice(device) {
|
||
// Check if already exists
|
||
const exists = tscmWifiDevices.some(d => d.bssid === device.bssid);
|
||
if (!exists) {
|
||
tscmWifiDevices.push(device);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
// Add to findings panel if score >= 3 (review level or higher)
|
||
if (device.score >= 3) {
|
||
addHighInterestDevice(device, 'wifi');
|
||
}
|
||
// Feed to baseline recorder if recording
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/wifi', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(device)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
}
|
||
}
|
||
|
||
function addTscmBtDevice(device) {
|
||
// Check if already exists
|
||
const exists = tscmBtDevices.some(d => d.mac === device.mac);
|
||
if (!exists) {
|
||
tscmBtDevices.push(device);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
// Add to threats panel if score >= 3 (review level or higher)
|
||
if (device.score >= 3) {
|
||
addHighInterestDevice(device, 'bluetooth');
|
||
}
|
||
// Feed to baseline recorder if recording
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/bluetooth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(device)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
}
|
||
}
|
||
|
||
let tscmRfSignals = [];
|
||
let tscmRfStatusMessage = null;
|
||
|
||
function addTscmRfSignal(signal) {
|
||
// Clear any error message since we're receiving signals
|
||
tscmRfStatusMessage = null;
|
||
// Check if already exists (within 0.1 MHz)
|
||
const exists = tscmRfSignals.some(s => Math.abs(s.frequency - signal.frequency) < 0.1);
|
||
if (!exists) {
|
||
tscmRfSignals.push(signal);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
// Add to findings panel if score >= 3 (review level or higher)
|
||
if (signal.score >= 3) {
|
||
addHighInterestDevice(signal, 'rf');
|
||
}
|
||
// Feed to baseline recorder if recording
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/rf', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(signal)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleRfStatus(data) {
|
||
// Store status message to display in RF panel
|
||
tscmRfStatusMessage = data.message;
|
||
updateRfDisplay();
|
||
}
|
||
|
||
function updateRfDisplay() {
|
||
const rfList = document.getElementById('tscmRfList');
|
||
if (!rfList) return;
|
||
|
||
if (tscmRfSignals.length === 0) {
|
||
if (tscmRfStatusMessage) {
|
||
rfList.innerHTML = `<div class="tscm-status-message"><span class="status-icon">⚠️</span> ${escapeHtml(tscmRfStatusMessage)}</div>`;
|
||
} else {
|
||
rfList.innerHTML = '<div class="tscm-empty">No RF signals detected</div>';
|
||
}
|
||
}
|
||
// If there are signals, updateTscmDisplays() will handle the display
|
||
}
|
||
|
||
// Track high-interest devices for the threats panel
|
||
let tscmHighInterestDevices = [];
|
||
function addHighInterestDevice(device, protocol) {
|
||
const id = device.mac || device.bssid || device.frequency;
|
||
const exists = tscmHighInterestDevices.some(d => d.id === id);
|
||
if (!exists) {
|
||
tscmHighInterestDevices.push({
|
||
id: id,
|
||
protocol: protocol,
|
||
name: device.name || device.ssid || `${device.frequency} MHz`,
|
||
score: device.score,
|
||
classification: device.classification,
|
||
indicators: device.indicators || [],
|
||
recommended_action: device.recommended_action,
|
||
device: device
|
||
});
|
||
updateHighInterestPanel();
|
||
}
|
||
}
|
||
|
||
function updateHighInterestPanel() {
|
||
const panel = document.getElementById('tscmThreatList');
|
||
if (tscmHighInterestDevices.length === 0) {
|
||
panel.innerHTML = '<div class="tscm-empty">No flagged findings yet</div>';
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...tscmHighInterestDevices].sort((a, b) => b.score - a.score);
|
||
panel.innerHTML = '<div class="tscm-threat-list">' + sorted.map(d => {
|
||
const severityClass = d.score >= 6 ? 'critical' : d.score >= 4 ? 'high' : 'medium';
|
||
return `
|
||
<div class="tscm-threat-item ${severityClass}" onclick="showDeviceDetails('${d.id}', '${d.protocol}')">
|
||
<div class="tscm-threat-header">
|
||
<span class="tscm-threat-type">${d.protocol.toUpperCase()}</span>
|
||
<span class="tscm-threat-severity">Score: ${d.score}</span>
|
||
</div>
|
||
<div class="tscm-threat-details">
|
||
<strong>${escapeHtml(d.name)}</strong><br>
|
||
<span style="font-size: 10px; color: var(--text-muted);">
|
||
${d.indicators && d.indicators.length > 0 ? d.indicators.slice(0, 2).map(i => i.desc || i.type).join(' | ') : 'Review recommended'}
|
||
</span>
|
||
</div>
|
||
<div class="tscm-threat-action">${d.recommended_action || 'review'}</div>
|
||
</div>
|
||
`;
|
||
}).join('') + '</div>';
|
||
}
|
||
}
|
||
|
||
function updateTscmProgress(data) {
|
||
// Update percentage text
|
||
document.getElementById('tscmProgressPercent').textContent = data.progress + '%';
|
||
|
||
// Update SVG circle progress (circumference = 2 * PI * 45 = ~283)
|
||
const circumference = 283;
|
||
const offset = circumference - (data.progress / 100) * circumference;
|
||
const circle = document.getElementById('tscmScannerCircle');
|
||
if (circle) {
|
||
circle.style.strokeDashoffset = offset;
|
||
}
|
||
|
||
// Update status text
|
||
let statusText = 'SCANNING...';
|
||
if (data.threats_found > 0) {
|
||
statusText = `THREATS: ${data.threats_found}`;
|
||
} else if (data.status) {
|
||
statusText = data.status;
|
||
} else {
|
||
const parts = [];
|
||
if (data.wifi_count > 0) parts.push(`${data.wifi_count} WiFi`);
|
||
if (data.bt_count > 0) parts.push(`${data.bt_count} BT`);
|
||
if (data.rf_count > 0) parts.push(`${data.rf_count} RF`);
|
||
statusText = parts.length > 0 ? parts.join(' | ') : 'SCANNING...';
|
||
}
|
||
document.getElementById('tscmProgressLabel').textContent = statusText;
|
||
}
|
||
|
||
function addTscmThreat(threat) {
|
||
tscmThreats.unshift(threat);
|
||
|
||
// Update dashboard counts
|
||
updateTscmThreatCounts();
|
||
updateTscmDisplays();
|
||
}
|
||
|
||
function updateTscmThreatCounts() {
|
||
// Count devices by new scoring model classification
|
||
const counts = { high_interest: 0, review: 0, informational: 0 };
|
||
|
||
// Count from all device lists
|
||
[...tscmWifiDevices, ...tscmBtDevices, ...tscmRfSignals].forEach(d => {
|
||
const classification = d.classification || 'review';
|
||
if (classification === 'high_interest') counts.high_interest++;
|
||
else if (classification === 'review') counts.review++;
|
||
else counts.informational++;
|
||
});
|
||
|
||
document.getElementById('tscmHighInterestCount').textContent = counts.high_interest;
|
||
document.getElementById('tscmNeedsReviewCount').textContent = counts.review;
|
||
document.getElementById('tscmInformationalCount').textContent = counts.informational;
|
||
document.getElementById('tscmCorrelationsCount').textContent = tscmCorrelations.length;
|
||
|
||
document.getElementById('tscmHighInterestCard').classList.toggle('active', counts.high_interest > 0);
|
||
document.getElementById('tscmNeedsReviewCard').classList.toggle('active', counts.review > 0);
|
||
document.getElementById('tscmInformationalCard').classList.toggle('active', counts.informational > 0);
|
||
document.getElementById('tscmCorrelationsCard').classList.toggle('active', tscmCorrelations.length > 0);
|
||
|
||
// Update threat panel count (now shows high interest items)
|
||
document.getElementById('tscmThreatCount').textContent = counts.high_interest;
|
||
}
|
||
|
||
function getClassificationClass(classification) {
|
||
// Map classification to CSS class
|
||
switch (classification) {
|
||
case 'high_interest': return 'classification-red';
|
||
case 'review': return 'classification-yellow';
|
||
case 'informational': return 'classification-green';
|
||
default: return 'classification-yellow';
|
||
}
|
||
}
|
||
|
||
function getClassificationIcon(classification) {
|
||
switch (classification) {
|
||
case 'high_interest': return '🔴';
|
||
case 'review': return '🟡';
|
||
case 'informational': return '🟢';
|
||
default: return '🟡';
|
||
}
|
||
}
|
||
|
||
function formatIndicators(indicators) {
|
||
if (!indicators || indicators.length === 0) return '';
|
||
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
|
||
}
|
||
|
||
function getScoreBadge(score) {
|
||
if (score === undefined || score === null) return '';
|
||
let scoreClass = 'score-low';
|
||
if (score >= 6) scoreClass = 'score-high';
|
||
else if (score >= 3) scoreClass = 'score-medium';
|
||
return `<span class="score-badge ${scoreClass}">Score: ${score}</span>`;
|
||
}
|
||
|
||
// Store all devices for lookup
|
||
function getAllTscmDevices() {
|
||
const devices = {};
|
||
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
|
||
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
|
||
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
|
||
return devices;
|
||
}
|
||
|
||
function showDeviceDetails(id, protocol) {
|
||
const devices = getAllTscmDevices();
|
||
const key = `${protocol}:${id}`;
|
||
const device = devices[key];
|
||
|
||
if (!device) {
|
||
console.warn('Device not found:', key);
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
// Build detailed view
|
||
let html = `
|
||
<div class="device-detail-header ${getClassificationClass(device.classification)}">
|
||
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(device.name || device.ssid || device.mac || device.bssid || device.frequency + ' MHz')}</h3>
|
||
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
|
||
</div>
|
||
|
||
<div class="device-detail-score">
|
||
<div class="score-circle ${device.score >= 6 ? 'high' : device.score >= 3 ? 'medium' : 'low'}">
|
||
<span class="score-value">${device.score || 0}</span>
|
||
<span class="score-label">SCORE</span>
|
||
</div>
|
||
<div class="score-breakdown">
|
||
<strong>Risk Level:</strong> ${device.classification === 'high_interest' ? 'HIGH INTEREST' : device.classification === 'review' ? 'NEEDS REVIEW' : 'INFORMATIONAL'}<br>
|
||
<strong>Recommended Action:</strong> ${device.recommended_action || 'Monitor'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="device-detail-section">
|
||
<h4>Device Information</h4>
|
||
<table class="device-detail-table">
|
||
`;
|
||
|
||
// Add device-specific fields
|
||
if (protocol === 'wifi') {
|
||
html += `
|
||
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
|
||
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
|
||
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
|
||
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
|
||
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
|
||
`;
|
||
} else if (protocol === 'bluetooth') {
|
||
html += `
|
||
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
|
||
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
|
||
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
|
||
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
|
||
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
|
||
`;
|
||
} else if (protocol === 'rf') {
|
||
html += `
|
||
<tr><td>Frequency</td><td>${device.frequency?.toFixed(3) || 'Unknown'} MHz</td></tr>
|
||
<tr><td>Band</td><td>${device.band || 'Unknown'}</td></tr>
|
||
<tr><td>Power</td><td>${device.power?.toFixed(1) || '--'} dBm</td></tr>
|
||
<tr><td>Signal Strength</td><td>+${(device.signal_strength || 0).toFixed(1)} dB above noise</td></tr>
|
||
`;
|
||
}
|
||
html += `</table></div>`;
|
||
|
||
// Actions section - always show with "Add to Known Devices"
|
||
const deviceIdentifier = device.bssid || device.mac || (device.frequency ? device.frequency.toString() : id);
|
||
const deviceName = device.name || device.ssid || device.essid || (device.frequency ? device.frequency + ' MHz' : 'Unknown');
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Actions</h4>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
`;
|
||
|
||
// Add "Listen" button for RF signals
|
||
if (protocol === 'rf' && device.frequency) {
|
||
const freq = device.frequency;
|
||
html += `
|
||
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'fm')">
|
||
🎧 Listen (FM)
|
||
</button>
|
||
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'am')">
|
||
🎧 Listen (AM)
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
// Add "Add to Known Devices" button for all device types
|
||
html += `
|
||
<button class="tscm-action-btn" style="background: var(--accent-cyan);" onclick="tscmAddToKnownDevices('${escapeHtml(deviceIdentifier)}', '${escapeHtml(deviceName)}', '${protocol}')">
|
||
✅ Add to Known Devices
|
||
</button>
|
||
</div>
|
||
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;">
|
||
${protocol === 'rf' ? 'Listen buttons open Listening Post. ' : ''}Known devices are excluded from threat scoring in future sweeps.
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Add indicators section
|
||
if (device.indicators && device.indicators.length > 0) {
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Risk Indicators (Why This Score)</h4>
|
||
<div class="indicator-list">
|
||
${device.indicators.map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${i.type}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.desc || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Add reasons section
|
||
if (device.reasons && device.reasons.length > 0) {
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Detection Notes</h4>
|
||
<ul class="device-reasons-list">
|
||
${device.reasons.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Add disclaimer
|
||
html += `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Disclaimer:</strong> This analysis identifies indicators and anomalies.
|
||
It does NOT confirm surveillance activity. Professional verification required.
|
||
</div>
|
||
`;
|
||
|
||
content.innerHTML = html;
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeTscmDeviceModal() {
|
||
document.getElementById('tscmDeviceModal').style.display = 'none';
|
||
}
|
||
|
||
function listenToRfSignal(frequency, modulation) {
|
||
// Close the modal
|
||
closeTscmDeviceModal();
|
||
|
||
// Switch to listening post mode
|
||
switchMode('listening');
|
||
|
||
// Wait a moment for the mode to switch, then tune to the frequency
|
||
setTimeout(() => {
|
||
if (typeof tuneToFrequency === 'function') {
|
||
tuneToFrequency(frequency, modulation);
|
||
} else {
|
||
// Fallback: manually update the frequency input
|
||
const freqInput = document.getElementById('radioScanStart');
|
||
if (freqInput) {
|
||
freqInput.value = frequency.toFixed(1);
|
||
}
|
||
alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`);
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function showDevicesByCategory(category) {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
let devices = [];
|
||
let title = '';
|
||
let titleClass = '';
|
||
|
||
if (category === 'correlations') {
|
||
// Show correlations
|
||
title = 'Cross-Protocol Correlations';
|
||
titleClass = 'classification-yellow';
|
||
|
||
if (tscmCorrelations.length === 0) {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="text-align: center; color: var(--text-muted);">No correlations detected yet.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title} (${tscmCorrelations.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
${tscmCorrelations.map(c => `
|
||
<div class="correlation-detail-item">
|
||
<strong>${escapeHtml(c.description || 'Cross-protocol match')}</strong>
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
|
||
Protocols: ${(c.protocols || []).join(', ')}<br>
|
||
Devices: ${(c.devices || []).join(', ')}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
modal.style.display = 'flex';
|
||
return;
|
||
}
|
||
|
||
// Filter devices by classification
|
||
const allDevices = [
|
||
...tscmWifiDevices.map(d => ({ ...d, protocol: 'wifi', id: d.bssid })),
|
||
...tscmBtDevices.map(d => ({ ...d, protocol: 'bluetooth', id: d.mac })),
|
||
...tscmRfSignals.map(d => ({ ...d, protocol: 'rf', id: d.frequency }))
|
||
];
|
||
|
||
if (category === 'high_interest') {
|
||
devices = allDevices.filter(d => d.classification === 'high_interest');
|
||
title = 'High Interest Devices';
|
||
titleClass = 'classification-red';
|
||
} else if (category === 'review') {
|
||
devices = allDevices.filter(d => d.classification === 'review');
|
||
title = 'Devices Needing Review';
|
||
titleClass = 'classification-yellow';
|
||
} else if (category === 'informational') {
|
||
devices = allDevices.filter(d => d.classification === 'informational');
|
||
title = 'Informational Devices';
|
||
titleClass = 'classification-green';
|
||
}
|
||
|
||
// Sort by score descending
|
||
devices.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
|
||
if (devices.length === 0) {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="text-align: center; color: var(--text-muted);">No devices in this category.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title} (${devices.length})</h3>
|
||
</div>
|
||
<div class="category-device-list">
|
||
${devices.map(d => `
|
||
<div class="category-device-item" onclick="event.stopPropagation(); showDeviceDetails('${d.id}', '${d.protocol}')">
|
||
<div class="category-device-header">
|
||
<span class="category-device-name">
|
||
${getClassificationIcon(d.classification)}
|
||
${escapeHtml(d.name || d.ssid || d.mac || d.bssid || d.frequency + ' MHz')}
|
||
</span>
|
||
<span class="category-device-score">${d.score || 0}</span>
|
||
</div>
|
||
<div class="category-device-meta">
|
||
<span class="protocol-badge">${d.protocol}</span>
|
||
${d.indicators ? d.indicators.slice(0, 2).map(i => `<span class="indicator-mini">${i.type}</span>`).join('') : ''}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function updateTscmDisplays() {
|
||
// Update WiFi list
|
||
const wifiList = document.getElementById('tscmWifiList');
|
||
if (tscmWifiDevices.length === 0) {
|
||
wifiList.innerHTML = '<div class="tscm-empty">No WiFi networks detected</div>';
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...tscmWifiDevices].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
wifiList.innerHTML = sorted.map(d => `
|
||
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.bssid}', 'wifi')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
|
||
${escapeHtml(d.ssid || d.bssid || 'Hidden')}
|
||
</div>
|
||
${getScoreBadge(d.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${d.bssid}</span>
|
||
<span>${d.signal || '--'} dBm</span>
|
||
<span>${d.security || 'Open'}</span>
|
||
</div>
|
||
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
||
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmWifiCount').textContent = tscmWifiDevices.length;
|
||
|
||
// Update BT list
|
||
const btList = document.getElementById('tscmBtList');
|
||
if (tscmBtDevices.length === 0) {
|
||
btList.innerHTML = '<div class="tscm-empty">No Bluetooth devices detected</div>';
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...tscmBtDevices].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
btList.innerHTML = sorted.map(d => `
|
||
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.mac}', 'bluetooth')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
|
||
${escapeHtml(d.name || 'Unknown')}
|
||
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">🎤</span>' : ''}
|
||
</div>
|
||
${getScoreBadge(d.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${d.mac}</span>
|
||
<span>${d.rssi || '--'} dBm</span>
|
||
<span>${d.device_type || 'Unknown'}</span>
|
||
</div>
|
||
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
||
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmBtCount').textContent = tscmBtDevices.length;
|
||
|
||
// Update RF list
|
||
const rfList = document.getElementById('tscmRfList');
|
||
if (tscmRfSignals.length === 0) {
|
||
if (tscmRfStatusMessage) {
|
||
rfList.innerHTML = `<div class="tscm-status-message"><span class="status-icon">⚠️</span> ${escapeHtml(tscmRfStatusMessage)}</div>`;
|
||
} else {
|
||
rfList.innerHTML = '<div class="tscm-empty">No RF signals detected</div>';
|
||
}
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...tscmRfSignals].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
rfList.innerHTML = sorted.map(s => `
|
||
<div class="tscm-device-item ${getClassificationClass(s.classification)}" onclick="showDeviceDetails('${s.frequency}', 'rf')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(s.classification)}</span>
|
||
${s.frequency.toFixed(3)} MHz
|
||
</div>
|
||
${getScoreBadge(s.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${s.band}</span>
|
||
<span>${s.power.toFixed(1)} dBm</span>
|
||
<span>+${(s.signal_strength || 0).toFixed(1)} dB above noise</span>
|
||
</div>
|
||
${s.indicators && s.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(s.indicators)}</div>` : ''}
|
||
${s.recommended_action && s.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${s.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmRfCount').textContent = tscmRfSignals.length;
|
||
|
||
// Update threats list
|
||
const threatList = document.getElementById('tscmThreatList');
|
||
if (tscmThreats.length === 0) {
|
||
threatList.innerHTML = '<div class="tscm-empty">No threats detected</div>';
|
||
} else {
|
||
threatList.innerHTML = '<div class="tscm-threat-list">' + tscmThreats.map(t => `
|
||
<div class="tscm-threat-item ${t.severity}">
|
||
<div class="tscm-threat-header">
|
||
<span class="tscm-threat-type">${escapeHtml(t.threat_type || 'Unknown')}</span>
|
||
<span class="tscm-threat-severity">${t.severity}</span>
|
||
</div>
|
||
<div class="tscm-threat-details">
|
||
<strong>${escapeHtml(t.name || t.identifier)}</strong><br>
|
||
Source: ${t.source} | Signal: ${t.signal_strength || '--'} dBm
|
||
</div>
|
||
</div>
|
||
`).join('') + '</div>';
|
||
}
|
||
}
|
||
|
||
function updateCorrelationsDisplay() {
|
||
const container = document.getElementById('tscmCorrelationsContainer');
|
||
if (!container) return;
|
||
|
||
if (tscmCorrelations.length === 0) {
|
||
container.innerHTML = '';
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
container.style.display = 'block';
|
||
container.innerHTML = `
|
||
<div class="tscm-correlations">
|
||
<h4>Cross-Protocol Correlations (${tscmCorrelations.length})</h4>
|
||
${tscmCorrelations.map(c => `
|
||
<div class="correlation-item">
|
||
<strong>${escapeHtml(c.description)}</strong>
|
||
<div class="correlation-devices">
|
||
Devices: ${c.devices.join(', ')} | Protocols: ${c.protocols.join(', ')}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function completeTscmSweep(data) {
|
||
isTscmRunning = false;
|
||
if (tscmEventSource) {
|
||
tscmEventSource.close();
|
||
tscmEventSource = null;
|
||
}
|
||
|
||
document.getElementById('startTscmBtn').style.display = 'block';
|
||
document.getElementById('stopTscmBtn').style.display = 'none';
|
||
document.getElementById('tscmProgress').style.display = 'none';
|
||
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
|
||
document.getElementById('tscmProgressPercent').textContent = '100%';
|
||
document.getElementById('tscmProgressBar').style.width = '100%';
|
||
|
||
// Final update of counts
|
||
updateTscmThreatCounts();
|
||
|
||
// Display sweep summary with correlation results
|
||
const summaryContainer = document.getElementById('tscmSweepSummary');
|
||
if (summaryContainer && data) {
|
||
const highInterest = data.high_interest_devices || 0;
|
||
const needsReview = data.needs_review_devices || 0;
|
||
const correlations = data.correlations_found || 0;
|
||
|
||
let assessment = 'BASELINE ENVIRONMENT';
|
||
let assessmentClass = 'informational';
|
||
if (highInterest > 0 || correlations > 0) {
|
||
assessment = 'ELEVATED CONCERN';
|
||
assessmentClass = 'high-interest';
|
||
} else if (needsReview > 3) {
|
||
assessment = 'MODERATE CONCERN';
|
||
assessmentClass = 'needs-review';
|
||
} else if (needsReview > 0) {
|
||
assessment = 'LOW CONCERN';
|
||
assessmentClass = 'needs-review';
|
||
}
|
||
|
||
summaryContainer.innerHTML = `
|
||
<div class="tscm-summary-box">
|
||
<div class="summary-stat high-interest">
|
||
<div class="count">${highInterest}</div>
|
||
<div class="label">High Interest</div>
|
||
</div>
|
||
<div class="summary-stat needs-review">
|
||
<div class="count">${needsReview}</div>
|
||
<div class="label">Needs Review</div>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<div class="count">${correlations}</div>
|
||
<div class="label">Correlations</div>
|
||
</div>
|
||
</div>
|
||
<div class="tscm-assessment ${assessmentClass}">
|
||
<strong>Assessment:</strong> ${assessment}
|
||
</div>
|
||
<div class="tscm-disclaimer">
|
||
This screening identifies wireless/RF anomalies, NOT confirmed surveillance devices.
|
||
Findings require professional verification.
|
||
</div>
|
||
`;
|
||
summaryContainer.style.display = 'block';
|
||
}
|
||
|
||
// Update correlations display
|
||
updateCorrelationsDisplay();
|
||
}
|
||
|
||
async function tscmRecordBaseline() {
|
||
const name = document.getElementById('tscmBaselineName').value ||
|
||
`Baseline ${new Date().toLocaleString()}`;
|
||
|
||
try {
|
||
const response = await fetch('/tscm/baseline/record', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
isRecordingBaseline = true;
|
||
document.getElementById('tscmRecordBaselineBtn').style.display = 'none';
|
||
document.getElementById('tscmStopBaselineBtn').style.display = 'block';
|
||
document.getElementById('tscmBaselineStatus').textContent = 'Recording baseline...';
|
||
document.getElementById('tscmBaselineStatus').style.color = '#ff9933';
|
||
} else {
|
||
alert(data.message || 'Failed to start baseline recording');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to start baseline:', e);
|
||
alert('Failed to start baseline recording');
|
||
}
|
||
}
|
||
|
||
async function tscmStopBaseline() {
|
||
try {
|
||
const response = await fetch('/tscm/baseline/stop', { method: 'POST' });
|
||
const data = await response.json();
|
||
|
||
isRecordingBaseline = false;
|
||
document.getElementById('tscmRecordBaselineBtn').style.display = 'block';
|
||
document.getElementById('tscmStopBaselineBtn').style.display = 'none';
|
||
|
||
if (data.status === 'success') {
|
||
document.getElementById('tscmBaselineStatus').textContent =
|
||
`Baseline saved: ${data.wifi_count} WiFi, ${data.bt_count} BT, ${data.rf_count} RF`;
|
||
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
|
||
loadTscmBaselines();
|
||
} else {
|
||
document.getElementById('tscmBaselineStatus').textContent = data.message || 'Recording stopped';
|
||
document.getElementById('tscmBaselineStatus').style.color = 'var(--text-muted)';
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to stop baseline:', e);
|
||
document.getElementById('tscmBaselineStatus').textContent = 'Error stopping baseline';
|
||
}
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ========== TSCM Advanced Features ==========
|
||
|
||
// Meeting Window Management
|
||
let tscmActiveMeetingId = null;
|
||
let tscmMeetingStartTime = null;
|
||
|
||
async function tscmStartMeeting() {
|
||
const meetingName = document.getElementById('tscmMeetingName').value ||
|
||
`Meeting ${new Date().toLocaleString()}`;
|
||
|
||
try {
|
||
const response = await fetch('/tscm/meeting/start-tracked', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: meetingName })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmActiveMeetingId = data.meeting_id;
|
||
tscmMeetingStartTime = new Date();
|
||
|
||
// Update UI
|
||
document.getElementById('tscmStartMeetingBtn').style.display = 'none';
|
||
document.getElementById('tscmEndMeetingBtn').style.display = 'block';
|
||
document.getElementById('tscmMeetingStatus').innerHTML =
|
||
`<span style="color: #ff9933;">Meeting active: ${escapeHtml(meetingName)}</span>`;
|
||
|
||
// Show meeting banner
|
||
const banner = document.getElementById('tscmMeetingBanner');
|
||
if (banner) {
|
||
banner.style.display = 'flex';
|
||
const nameSpan = document.getElementById('tscmMeetingName_display');
|
||
if (nameSpan) nameSpan.textContent = meetingName;
|
||
}
|
||
} else {
|
||
alert(data.message || 'Failed to start meeting window');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to start meeting:', e);
|
||
alert('Failed to start meeting window');
|
||
}
|
||
}
|
||
|
||
async function tscmEndMeeting() {
|
||
if (!tscmActiveMeetingId) return;
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/meeting/${tscmActiveMeetingId}/end`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
// Update UI
|
||
document.getElementById('tscmStartMeetingBtn').style.display = 'block';
|
||
document.getElementById('tscmEndMeetingBtn').style.display = 'none';
|
||
|
||
// Hide meeting banner
|
||
const banner = document.getElementById('tscmMeetingBanner');
|
||
if (banner) banner.style.display = 'none';
|
||
|
||
if (data.status === 'success') {
|
||
const duration = tscmMeetingStartTime ?
|
||
Math.round((new Date() - tscmMeetingStartTime) / 60000) : 0;
|
||
document.getElementById('tscmMeetingStatus').innerHTML =
|
||
`<span style="color: #00ff88;">Meeting ended (${duration} min) - ${data.devices_flagged || 0} devices flagged</span>`;
|
||
|
||
// Show export section if devices were flagged
|
||
if (data.devices_flagged > 0) {
|
||
document.getElementById('tscmExportSection').style.display = 'block';
|
||
}
|
||
} else {
|
||
document.getElementById('tscmMeetingStatus').textContent = 'Meeting ended';
|
||
}
|
||
|
||
tscmActiveMeetingId = null;
|
||
tscmMeetingStartTime = null;
|
||
} catch (e) {
|
||
console.error('Failed to end meeting:', e);
|
||
}
|
||
}
|
||
|
||
// Capabilities Display
|
||
async function tscmShowCapabilities() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading capabilities...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/capabilities');
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const caps = data.capabilities;
|
||
|
||
// Determine availability from nested structure
|
||
const wifiAvailable = caps.wifi && caps.wifi.mode !== 'unavailable';
|
||
const btAvailable = caps.bluetooth && caps.bluetooth.mode !== 'unavailable';
|
||
const rfAvailable = caps.rf && caps.rf.available;
|
||
|
||
// Build can/cannot detect lists based on capabilities
|
||
const canDetect = [];
|
||
const cannotDetect = [];
|
||
|
||
if (wifiAvailable) {
|
||
canDetect.push('WiFi access points and networks');
|
||
canDetect.push('Hidden SSIDs (presence only)');
|
||
if (caps.wifi.monitor_capable) {
|
||
canDetect.push('WiFi client devices (probe requests)');
|
||
canDetect.push('Deauthentication attacks');
|
||
}
|
||
} else {
|
||
cannotDetect.push('WiFi networks - no adapter available');
|
||
}
|
||
|
||
if (btAvailable) {
|
||
canDetect.push('Bluetooth Classic devices');
|
||
canDetect.push('BLE beacons and trackers');
|
||
canDetect.push('Audio-capable Bluetooth devices');
|
||
} else {
|
||
cannotDetect.push('Bluetooth devices - no adapter available');
|
||
}
|
||
|
||
if (rfAvailable) {
|
||
const minFreq = caps.rf.frequency_range_mhz?.min || 0;
|
||
const maxFreq = caps.rf.frequency_range_mhz?.max || 0;
|
||
canDetect.push(`RF signals (${minFreq}-${maxFreq} MHz)`);
|
||
canDetect.push('Unknown transmitters in frequency range');
|
||
} else {
|
||
cannotDetect.push('RF signals - no SDR device available');
|
||
}
|
||
|
||
// Always cannot detect
|
||
cannotDetect.push('Wired surveillance devices');
|
||
cannotDetect.push('Passive listening devices (no transmitter)');
|
||
cannotDetect.push('Devices that are powered off');
|
||
cannotDetect.push('Burst/store-and-forward transmitters (when idle)');
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>Sweep Capabilities</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>System Information</h4>
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">
|
||
OS: ${escapeHtml(caps.system?.os || 'Unknown')} ${escapeHtml(caps.system?.os_version || '')} |
|
||
Root: ${caps.system?.is_root ? 'Yes' : 'No'}
|
||
</div>
|
||
<h4>Available Detection Methods</h4>
|
||
<div class="capabilities-grid">
|
||
<div class="cap-detail-item ${wifiAvailable ? 'available' : 'unavailable'}">
|
||
<span class="cap-icon">📶</span>
|
||
<span class="cap-name">WiFi Scanning</span>
|
||
<span class="cap-status">${wifiAvailable ? caps.wifi.mode : 'Not Available'}</span>
|
||
${caps.wifi?.interface ? `<span class="cap-detail">${escapeHtml(caps.wifi.interface)}</span>` : ''}
|
||
</div>
|
||
<div class="cap-detail-item ${btAvailable ? 'available' : 'unavailable'}">
|
||
<span class="cap-icon">🔵</span>
|
||
<span class="cap-name">Bluetooth Scanning</span>
|
||
<span class="cap-status">${btAvailable ? caps.bluetooth.mode : 'Not Available'}</span>
|
||
${caps.bluetooth?.adapter ? `<span class="cap-detail">${escapeHtml(caps.bluetooth.adapter)}</span>` : ''}
|
||
</div>
|
||
<div class="cap-detail-item ${rfAvailable ? 'available' : 'unavailable'}">
|
||
<span class="cap-icon">📡</span>
|
||
<span class="cap-name">RF/SDR Scanning</span>
|
||
<span class="cap-status">${rfAvailable ? 'Available' : 'Not Available'}</span>
|
||
${caps.rf?.device_type ? `<span class="cap-detail">${escapeHtml(caps.rf.device_type)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>What This Sweep CAN Detect</h4>
|
||
<ul class="cap-can-list">
|
||
${canDetect.map(item => `<li>✅ ${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>What This Sweep CANNOT Detect</h4>
|
||
<ul class="cap-cannot-list">
|
||
${cannotDetect.map(item => `<li>❌ ${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
${caps.all_limitations && caps.all_limitations.length > 0 ? `
|
||
<div class="device-detail-section">
|
||
<h4>Current Limitations</h4>
|
||
<ul class="cap-cannot-list">
|
||
${caps.all_limitations.map(item => `<li>⚠️ ${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Important:</strong> ${escapeHtml(caps.disclaimer || 'This tool detects wireless RF emissions only. Professional TSCM requires physical inspection, NLJD, thermal imaging, and spectrum analysis equipment.')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `<div style="padding: 20px; color: #ff6666;">Failed to load capabilities: ${data.message || 'Unknown error'}</div>`;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load capabilities:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load capabilities</div>';
|
||
}
|
||
}
|
||
|
||
// Known Devices Management
|
||
async function tscmShowKnownDevices() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading known devices...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/known-devices');
|
||
const data = await response.json();
|
||
|
||
const devices = data.devices || [];
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-green">
|
||
<h3>✅ Known/Approved Devices (${devices.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<div style="margin-bottom: 12px;">
|
||
<button class="preset-btn" onclick="tscmAddKnownDevice()" style="font-size: 11px;">
|
||
+ Add Device
|
||
</button>
|
||
</div>
|
||
${devices.length === 0 ?
|
||
'<p style="color: var(--text-muted);">No known devices registered. Devices you mark as "known" will be excluded from threat scoring.</p>' :
|
||
`<div class="known-devices-list">
|
||
${devices.map(d => `
|
||
<div class="known-device-item">
|
||
<div class="known-device-info">
|
||
<strong>${escapeHtml(d.name || d.identifier)}</strong>
|
||
<span class="known-device-id">${escapeHtml(d.identifier)}</span>
|
||
<span class="known-device-type">${d.device_type}</span>
|
||
</div>
|
||
<div class="known-device-actions">
|
||
<button class="preset-btn" onclick="tscmRemoveKnownDevice(${d.id})" style="font-size: 10px; background: #ff4444;">
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load known devices:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load known devices</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmAddKnownDevice() {
|
||
const identifier = prompt('Enter device identifier (MAC address, BSSID, or frequency):');
|
||
if (!identifier) return;
|
||
|
||
const name = prompt('Enter friendly name for this device:');
|
||
const protocol = prompt('Enter protocol type (wifi/bluetooth/rf):') || 'wifi';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/known-devices', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
identifier: identifier,
|
||
protocol: protocol,
|
||
name: name || identifier
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowKnownDevices(); // Refresh list
|
||
} else {
|
||
alert(data.message || 'Failed to add device');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to add known device:', e);
|
||
alert('Failed to add device');
|
||
}
|
||
}
|
||
|
||
async function tscmRemoveKnownDevice(deviceId) {
|
||
if (!confirm('Remove this device from known devices list?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/known-devices/${deviceId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowKnownDevices(); // Refresh list
|
||
} else {
|
||
alert(data.message || 'Failed to remove device');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to remove known device:', e);
|
||
}
|
||
}
|
||
|
||
async function tscmAddToKnownDevices(identifier, name, protocol) {
|
||
// Ask for optional custom name
|
||
const customName = prompt(`Add "${name}" to known devices.\n\nEnter a friendly name (or leave blank to use default):`, name);
|
||
if (customName === null) return; // User cancelled
|
||
|
||
try {
|
||
const response = await fetch('/tscm/known-devices', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
identifier: identifier,
|
||
protocol: protocol,
|
||
name: customName || name
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
// Show success message
|
||
alert(`"${customName || name}" added to known devices.\n\nThis device will be excluded from threat scoring in future sweeps.`);
|
||
// Close the device modal
|
||
closeTscmDeviceModal();
|
||
} else {
|
||
alert(data.message || 'Failed to add device');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to add to known devices:', e);
|
||
alert('Failed to add device to known list');
|
||
}
|
||
}
|
||
|
||
// Cases Management
|
||
async function tscmShowCases() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading cases...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/cases');
|
||
const data = await response.json();
|
||
|
||
const cases = data.cases || [];
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>📁 TSCM Cases (${cases.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<div style="margin-bottom: 12px;">
|
||
<button class="preset-btn" onclick="tscmCreateCase()" style="font-size: 11px;">
|
||
+ New Case
|
||
</button>
|
||
</div>
|
||
${cases.length === 0 ?
|
||
'<p style="color: var(--text-muted);">No cases created. Cases help you organize sweeps and findings for specific locations or clients.</p>' :
|
||
`<div class="cases-list">
|
||
${cases.map(c => `
|
||
<div class="case-item" onclick="tscmViewCase(${c.id})">
|
||
<div class="case-header">
|
||
<strong>${escapeHtml(c.name)}</strong>
|
||
<span class="case-status ${c.status}">${c.status}</span>
|
||
</div>
|
||
<div class="case-meta">
|
||
${c.client_name ? `Client: ${escapeHtml(c.client_name)} | ` : ''}
|
||
${c.location ? `Location: ${escapeHtml(c.location)} | ` : ''}
|
||
Sweeps: ${c.sweep_count || 0} | Threats: ${c.threat_count || 0}
|
||
</div>
|
||
<div class="case-date">
|
||
Created: ${new Date(c.created_at).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load cases:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load cases</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmCreateCase() {
|
||
const name = prompt('Enter case name:');
|
||
if (!name) return;
|
||
|
||
const clientName = prompt('Enter client name (optional):');
|
||
const location = prompt('Enter location (optional):');
|
||
|
||
try {
|
||
const response = await fetch('/tscm/cases', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name,
|
||
client_name: clientName || null,
|
||
location: location || null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowCases(); // Refresh list
|
||
} else {
|
||
alert(data.message || 'Failed to create case');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to create case:', e);
|
||
alert('Failed to create case');
|
||
}
|
||
}
|
||
|
||
async function tscmViewCase(caseId) {
|
||
try {
|
||
const response = await fetch(`/tscm/cases/${caseId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const c = data.case;
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>📁 ${escapeHtml(c.name)}</h3>
|
||
<span class="case-status ${c.status}">${c.status}</span>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Case Details</h4>
|
||
<table class="device-detail-table">
|
||
<tr><td>Client</td><td>${escapeHtml(c.client_name || 'N/A')}</td></tr>
|
||
<tr><td>Location</td><td>${escapeHtml(c.location || 'N/A')}</td></tr>
|
||
<tr><td>Created</td><td>${new Date(c.created_at).toLocaleString()}</td></tr>
|
||
<tr><td>Status</td><td>${c.status}</td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Linked Sweeps (${(c.sweeps || []).length})</h4>
|
||
${(c.sweeps || []).length === 0 ?
|
||
'<p style="color: var(--text-muted);">No sweeps linked to this case yet.</p>' :
|
||
`<ul>${(c.sweeps || []).map(s => `<li>Sweep ${s.id} - ${new Date(s.timestamp).toLocaleString()}</li>`).join('')}</ul>`
|
||
}
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Flagged Threats (${(c.threats || []).length})</h4>
|
||
${(c.threats || []).length === 0 ?
|
||
'<p style="color: var(--text-muted);">No threats flagged in this case.</p>' :
|
||
`<ul>${(c.threats || []).map(t => `<li>${escapeHtml(t.identifier)} - ${t.threat_type}</li>`).join('')}</ul>`
|
||
}
|
||
</div>
|
||
<div style="margin-top: 16px;">
|
||
<button class="preset-btn" onclick="tscmShowCases()">← Back to Cases</button>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to view case:', e);
|
||
}
|
||
}
|
||
|
||
// Playbooks Display
|
||
async function tscmShowPlaybooks() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading playbooks...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/playbooks');
|
||
const data = await response.json();
|
||
|
||
const playbooks = data.playbooks || [];
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-orange">
|
||
<h3>📋 Operator Playbooks</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="color: var(--text-muted); margin-bottom: 16px;">
|
||
Playbooks provide step-by-step guidance for investigating specific types of findings.
|
||
</p>
|
||
<div class="playbooks-list">
|
||
${playbooks.map(p => `
|
||
<div class="playbook-item" onclick="tscmViewPlaybook('${p.id}')">
|
||
<div class="playbook-header">
|
||
<strong>${escapeHtml(p.name || p.title)}</strong>
|
||
<span class="playbook-category">${escapeHtml(p.risk_level || p.category || 'General')}</span>
|
||
</div>
|
||
<div class="playbook-desc">
|
||
${escapeHtml(p.description || 'No description')}
|
||
</div>
|
||
<div class="playbook-meta">
|
||
${p.steps?.length || 0} steps
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load playbooks:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load playbooks</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmViewPlaybook(playbookId) {
|
||
try {
|
||
const response = await fetch(`/tscm/playbooks/${playbookId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const p = data.playbook;
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-orange">
|
||
<h3>📋 ${escapeHtml(p.title || p.name || 'Playbook')}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p>${escapeHtml(p.description || '')}</p>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Steps</h4>
|
||
<ol class="playbook-steps">
|
||
${(p.steps || []).map((step, i) => `
|
||
<li class="playbook-step">
|
||
<strong>${escapeHtml(step.action || step.title || `Step ${step.step || i + 1}`)}</strong>
|
||
<p>${escapeHtml(step.details || step.description || '')}</p>
|
||
${step.safety_note ? `<div class="playbook-warning">⚠️ ${escapeHtml(step.safety_note)}</div>` : ''}
|
||
</li>
|
||
`).join('')}
|
||
</ol>
|
||
</div>
|
||
${p.when_to_escalate ? `
|
||
<div class="device-detail-section">
|
||
<h4>When to Escalate</h4>
|
||
<p>${escapeHtml(p.when_to_escalate)}</p>
|
||
</div>
|
||
` : ''}
|
||
${p.documentation_required && p.documentation_required.length > 0 ? `
|
||
<div class="device-detail-section">
|
||
<h4>Documentation Required</h4>
|
||
<ul>
|
||
${p.documentation_required.map(d => `<li>${escapeHtml(d)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
${p.disclaimer ? `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Disclaimer:</strong> ${escapeHtml(p.disclaimer)}
|
||
</div>
|
||
` : ''}
|
||
<div style="margin-top: 16px;">
|
||
<button class="preset-btn" onclick="tscmShowPlaybooks()">← Back to Playbooks</button>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to view playbook:', e);
|
||
}
|
||
}
|
||
|
||
// Report Downloads
|
||
async function tscmDownloadPdf() {
|
||
try {
|
||
const response = await fetch('/tscm/report/pdf');
|
||
if (response.ok) {
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `TSCM_Report_${new Date().toISOString().split('T')[0]}.pdf`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} else {
|
||
const data = await response.json();
|
||
alert(data.message || 'Failed to generate PDF');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to download PDF:', e);
|
||
alert('Failed to download PDF report');
|
||
}
|
||
}
|
||
|
||
async function tscmDownloadAnnex(format) {
|
||
try {
|
||
const response = await fetch(`/tscm/report/annex?format=${format}`);
|
||
if (response.ok) {
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `TSCM_Annex_${new Date().toISOString().split('T')[0]}.${format}`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} else {
|
||
const data = await response.json();
|
||
alert(data.message || 'Failed to generate annex');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to download annex:', e);
|
||
alert('Failed to download technical annex');
|
||
}
|
||
}
|
||
|
||
// Update capabilities bar on sweep start
|
||
async function updateTscmCapabilitiesBar() {
|
||
try {
|
||
const response = await fetch('/tscm/capabilities');
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const caps = data.capabilities;
|
||
const bar = document.getElementById('tscmCapabilitiesBar');
|
||
|
||
if (bar) {
|
||
document.getElementById('capWifiStatus').textContent = caps.wifi_available ? 'ON' : 'OFF';
|
||
document.getElementById('capWifi').classList.toggle('active', caps.wifi_available);
|
||
|
||
document.getElementById('capBtStatus').textContent = caps.bluetooth_available ? 'ON' : 'OFF';
|
||
document.getElementById('capBt').classList.toggle('active', caps.bluetooth_available);
|
||
|
||
document.getElementById('capSdrStatus').textContent = caps.sdr_available ? 'ON' : 'OFF';
|
||
document.getElementById('capSdr').classList.toggle('active', caps.sdr_available);
|
||
|
||
bar.style.display = 'flex';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to update capabilities bar:', e);
|
||
}
|
||
}
|
||
|
||
// Update baseline health indicator
|
||
async function updateTscmBaselineHealth(baselineId) {
|
||
if (!baselineId) {
|
||
const healthDiv = document.getElementById('tscmBaselineHealth');
|
||
if (healthDiv) healthDiv.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/baseline/${baselineId}/health`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const healthDiv = document.getElementById('tscmBaselineHealth');
|
||
const badge = document.getElementById('baselineHealthBadge');
|
||
|
||
if (healthDiv && badge) {
|
||
badge.textContent = data.health_status || 'Unknown';
|
||
badge.className = `health-badge health-${(data.health_status || 'unknown').toLowerCase()}`;
|
||
healthDiv.style.display = 'block';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to update baseline health:', e);
|
||
}
|
||
}
|
||
|
||
// Listen for baseline selection changes
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const baselineSelect = document.getElementById('tscmBaselineSelect');
|
||
if (baselineSelect) {
|
||
baselineSelect.addEventListener('change', function () {
|
||
updateTscmBaselineHealth(this.value);
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
|
||
|
||
<!-- Help Modal -->
|
||
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
|
||
<div class="help-content">
|
||
<button class="help-close" onclick="hideHelp()">×</button>
|
||
<h2>📡 iNTERCEPT Help</h2>
|
||
|
||
<div class="help-tabs">
|
||
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
|
||
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
|
||
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
|
||
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
|
||
</div>
|
||
|
||
<!-- Icons Section -->
|
||
<div id="help-icons" class="help-section active">
|
||
<h3>Stats Bar Icons</h3>
|
||
<div class="icon-grid">
|
||
<div class="icon-item"><span class="icon">📟</span><span class="desc">POCSAG messages decoded</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">📠</span><span class="desc">FLEX messages decoded</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">📨</span><span class="desc">Total messages received</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">🌡️</span><span class="desc">Unique sensors
|
||
detected</span></div>
|
||
<div class="icon-item"><span class="icon">📊</span><span class="desc">Device types found</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellites monitored</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">📡</span><span class="desc">WiFi Access Points</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">👤</span><span class="desc">Connected WiFi clients</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">🤝</span><span class="desc">Captured handshakes</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">🚁</span><span class="desc">Detected drones (click for
|
||
details)</span></div>
|
||
<div class="icon-item"><span class="icon">⚠️</span><span class="desc">Rogue APs (click for
|
||
details)</span></div>
|
||
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth devices</span></div>
|
||
<div class="icon-item"><span class="icon">📍</span><span class="desc">BLE beacons / APRS
|
||
stations</span></div>
|
||
</div>
|
||
|
||
<h3>Mode Tab Icons</h3>
|
||
<div class="icon-grid">
|
||
<div class="icon-item"><span class="icon">📟</span><span class="desc">Pager - POCSAG/FLEX
|
||
decoder</span></div>
|
||
<div class="icon-item"><span class="icon">📡</span><span class="desc">433MHz - Sensor decoder</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - Opens ADS-B
|
||
Dashboard</span></div>
|
||
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio
|
||
tracking</span></div>
|
||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass
|
||
prediction</span></div>
|
||
<div class="icon-item"><span class="icon">📶</span><span class="desc">WiFi - Network scanner</span>
|
||
</div>
|
||
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth - BT/BLE
|
||
scanner</span></div>
|
||
<div class="icon-item"><span class="icon">📻</span><span class="desc">Listening Post - SDR
|
||
scanner</span></div>
|
||
<div class="icon-item"><span class="icon">🔍</span><span class="desc">TSCM -
|
||
Counter-surveillance</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modes Section -->
|
||
<div id="help-modes" class="help-section">
|
||
<h3>📟 Pager Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
|
||
<li>Set frequency to local pager frequencies (common: 152-158 MHz)</li>
|
||
<li>Messages are displayed in real-time as they're decoded</li>
|
||
<li>Use presets for common pager frequencies</li>
|
||
</ul>
|
||
|
||
<h3>📡 433MHz Sensor Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Decodes wireless sensors on 433.92 MHz ISM band</li>
|
||
<li>Detects temperature, humidity, weather stations, tire pressure monitors</li>
|
||
<li>Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)</li>
|
||
<li>Device intelligence builds profiles of recurring devices</li>
|
||
</ul>
|
||
|
||
<h3>✈️ Aircraft (Dashboard)</h3>
|
||
<ul class="tip-list">
|
||
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
|
||
<li>Features radar scope, map view, airband audio, and ACARS decoding</li>
|
||
</ul>
|
||
|
||
<h3>📍 APRS Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
|
||
<li>Tracks amateur radio operators transmitting position data</li>
|
||
<li>Regional frequencies: 144.390 MHz (N. America), 144.800 MHz (Europe)</li>
|
||
<li>Uses Direwolf or multimon-ng for packet decoding</li>
|
||
<li>Interactive map shows station positions in real-time</li>
|
||
</ul>
|
||
|
||
<h3>🛰️ Satellite Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Track satellites using TLE (Two-Line Element) data</li>
|
||
<li>Add satellites manually or fetch from Celestrak by category</li>
|
||
<li>Categories: Amateur, Weather, ISS, Starlink, GPS, and more</li>
|
||
<li>View next pass predictions with elevation and duration</li>
|
||
</ul>
|
||
|
||
<h3>📶 WiFi Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Requires a WiFi adapter capable of monitor mode</li>
|
||
<li>Click "Enable Monitor" to put adapter in monitor mode</li>
|
||
<li>Scans all channels or lock to a specific channel</li>
|
||
<li>Detects drones by SSID patterns and manufacturer OUI</li>
|
||
<li>Rogue AP detection flags same SSID on multiple BSSIDs</li>
|
||
<li>Click network rows to target for deauth or handshake capture</li>
|
||
</ul>
|
||
|
||
<h3>🔵 Bluetooth Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Scans for classic Bluetooth and BLE devices</li>
|
||
<li>Shows device names, addresses, and signal strength</li>
|
||
<li>Manufacturer lookup from MAC address OUI</li>
|
||
<li>Radar visualization shows device proximity</li>
|
||
</ul>
|
||
|
||
<h3>📻 Listening Post Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Wideband SDR scanner with spectrum visualization</li>
|
||
<li>Tune to any frequency supported by your SDR hardware</li>
|
||
<li>AM/FM/USB/LSB demodulation modes</li>
|
||
<li>Bookmark frequencies for quick recall</li>
|
||
<li>Quick tune presets for emergency and marine channels</li>
|
||
</ul>
|
||
|
||
<h3>🔍 TSCM Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Technical Surveillance Countermeasures sweep</li>
|
||
<li>Scans for unknown RF transmitters, WiFi devices, Bluetooth</li>
|
||
<li>Baseline comparison to detect new/anomalous devices</li>
|
||
<li>Threat classification: Critical, High, Medium, Low</li>
|
||
<li>Useful for security audits and bug sweeps</li>
|
||
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- WiFi Section -->
|
||
<div id="help-wifi" class="help-section">
|
||
<h3>Monitor Mode</h3>
|
||
<ul class="tip-list">
|
||
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
||
<li><strong>Kill Processes:</strong> Optional - stops NetworkManager/wpa_supplicant (may drop other
|
||
connections)</li>
|
||
<li>Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon)</li>
|
||
</ul>
|
||
|
||
<h3>Handshake Capture</h3>
|
||
<ul class="tip-list">
|
||
<li>Click "Capture" on a network to start targeted handshake capture</li>
|
||
<li>Status panel shows capture progress and file location</li>
|
||
<li>Use deauth to force clients to reconnect (only on authorized networks!)</li>
|
||
<li>Handshake files saved to /tmp/intercept_handshake_*.cap</li>
|
||
</ul>
|
||
|
||
<h3>Drone Detection</h3>
|
||
<ul class="tip-list">
|
||
<li>Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)</li>
|
||
<li>Also detected by manufacturer OUI in MAC address</li>
|
||
<li>Distance estimated from signal strength (approximate)</li>
|
||
<li>Click drone count in stats bar to see all detected drones</li>
|
||
</ul>
|
||
|
||
<h3>Rogue AP Detection</h3>
|
||
<ul class="tip-list">
|
||
<li>Flags networks where same SSID appears on multiple BSSIDs</li>
|
||
<li>Could indicate evil twin attack or legitimate multi-AP setup</li>
|
||
<li>Click rogue count to see which SSIDs are flagged</li>
|
||
</ul>
|
||
|
||
<h3>Proximity Alerts</h3>
|
||
<ul class="tip-list">
|
||
<li>Add MAC addresses to watch list for alerts when detected</li>
|
||
<li>Watch list persists in browser localStorage</li>
|
||
<li>Useful for tracking specific devices</li>
|
||
</ul>
|
||
|
||
<h3>Client Probe Analysis</h3>
|
||
<ul class="tip-list">
|
||
<li>Shows what networks client devices are looking for</li>
|
||
<li>Orange highlights indicate sensitive/private network names</li>
|
||
<li>Reveals user location history (home, work, hotels, airports)</li>
|
||
<li>Useful for security awareness and pen test reports</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Tips Section -->
|
||
<div id="help-tips" class="help-section">
|
||
<h3>General Tips</h3>
|
||
<ul class="tip-list">
|
||
<li><strong>Collapsible sections:</strong> Click any section header (▼) to collapse/expand</li>
|
||
<li><strong>Sound alerts:</strong> Toggle sound on/off in settings for each mode</li>
|
||
<li><strong>Export data:</strong> Use export buttons to save captured data as JSON</li>
|
||
<li><strong>Device Intelligence:</strong> Tracks device patterns over time</li>
|
||
<li><strong>Theme toggle:</strong> Click 🌙/☀️ button in header to switch dark/light mode</li>
|
||
</ul>
|
||
|
||
<h3>Keyboard Shortcuts</h3>
|
||
<ul class="tip-list">
|
||
<li><strong>F1</strong> - Open this help page</li>
|
||
<li><strong>?</strong> - Open help (when not typing in a field)</li>
|
||
<li><strong>Escape</strong> - Close help and modal dialogs</li>
|
||
</ul>
|
||
|
||
<h3>Requirements</h3>
|
||
<ul class="tip-list">
|
||
<li><strong>Pager:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
|
||
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
|
||
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
|
||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
|
||
<li>Run as root/sudo for full hardware access</li>
|
||
</ul>
|
||
|
||
<h3>Legal Notice</h3>
|
||
<ul class="tip-list">
|
||
<li>Only use on networks and devices you own or have authorization to test</li>
|
||
<li>Passive monitoring may be legal; active attacks require authorization</li>
|
||
<li>Check local laws regarding radio frequency monitoring</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dependencies Modal -->
|
||
<div id="depsModal" class="help-modal" onclick="if(event.target === this) hideDependencies()">
|
||
<div class="help-content" style="max-width: 800px;">
|
||
<button class="help-close" onclick="hideDependencies()">×</button>
|
||
<h2>🔧 Tool Dependencies</h2>
|
||
<p style="color: var(--text-dim); margin-bottom: 15px;">Check which tools are installed for each mode. <span
|
||
style="color: var(--accent-green);">●</span> = Installed, <span
|
||
style="color: var(--accent-red);">●</span> = Missing</p>
|
||
<div id="depsContent" style="max-height: 60vh; overflow-y: auto;">
|
||
<div style="text-align: center; padding: 40px; color: var(--text-dim);">
|
||
Loading dependencies...
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--border-color);">
|
||
<h3 style="margin-bottom: 10px;">Quick Install (Debian/Ubuntu)</h3>
|
||
<div
|
||
style="background: var(--bg-tertiary); padding: 10px; border-radius: 4px; font-family: monospace; font-size: 11px; overflow-x: auto;">
|
||
<div>sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxdumptool
|
||
hcxtools</div>
|
||
<div style="margin-top: 5px;">pip install skyfield flask</div>
|
||
</div>
|
||
<div style="margin-top: 10px; font-size: 11px; color: var(--text-dim);">
|
||
<strong>Note:</strong> ACARS decoding requires <code>acarsdec</code> which must be built from
|
||
source.
|
||
See <a href="https://github.com/TLeconte/acarsdec" target="_blank"
|
||
style="color: var(--accent-cyan);">github.com/TLeconte/acarsdec</a> or run
|
||
<code>./setup.sh</code> for automated installation.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Add Modal -->
|
||
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
|
||
<div class="help-content" style="max-width: 600px;">
|
||
<button class="help-close" onclick="closeSatModal()">×</button>
|
||
<h2>Add Satellites</h2>
|
||
|
||
<!-- Tabs -->
|
||
<div
|
||
style="display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;">
|
||
<button class="sat-modal-tab preset-btn active" onclick="switchSatModalTab('tle')"
|
||
style="flex: 1;">Manual TLE</button>
|
||
<button class="sat-modal-tab preset-btn" onclick="switchSatModalTab('celestrak')"
|
||
style="flex: 1;">Celestrak</button>
|
||
</div>
|
||
|
||
<!-- TLE Section -->
|
||
<div id="tleSection" class="sat-modal-section active">
|
||
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
|
||
Paste TLE (Two-Line Element) data. Format: Name on first line, followed by TLE lines 1 and 2.
|
||
</p>
|
||
<textarea id="tleInput" placeholder="ISS (ZARYA)
|
||
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||
2 25544 51.6400 208.9163 0006703 296.5855 63.4606 15.49995465478450"
|
||
style="width: 100%; height: 150px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; resize: vertical;"></textarea>
|
||
<button class="preset-btn" onclick="addFromTLE()" style="margin-top: 10px; width: 100%;">Add
|
||
Satellite</button>
|
||
</div>
|
||
|
||
<!-- Celestrak Section -->
|
||
<div id="celestrakSection" class="sat-modal-section">
|
||
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
|
||
Fetch satellite TLE data from CelesTrak by category.
|
||
</p>
|
||
<div id="celestrakStatus" style="margin-bottom: 10px; font-size: 11px; min-height: 20px;"></div>
|
||
|
||
<div
|
||
style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;">
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('noaa')">NOAA</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('starlink')">Starlink</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('oneweb')">OneWeb</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('iridium-NEXT')">Iridium NEXT</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('visual')">Bright/Visual</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('geo')">Geostationary</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('resource')">Earth Resources</button>
|
||
</div>
|
||
|
||
<p style="color: var(--text-muted); font-size: 10px; margin-top: 10px;">
|
||
Data from <a href="https://celestrak.org" target="_blank"
|
||
style="color: var(--accent-cyan);">celestrak.org</a>.
|
||
Note: Some categories (Starlink, OneWeb) contain many satellites.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function showDependencies() {
|
||
document.getElementById('depsModal').classList.add('active');
|
||
loadDependencies();
|
||
}
|
||
|
||
function hideDependencies() {
|
||
document.getElementById('depsModal').classList.remove('active');
|
||
}
|
||
|
||
function loadDependencies() {
|
||
const content = document.getElementById('depsContent');
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--text-dim);">Loading dependencies...</div>';
|
||
|
||
fetch('/dependencies')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status !== 'success') {
|
||
content.innerHTML = '<div style="color: var(--accent-red);">Error loading dependencies</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
let totalMissing = 0;
|
||
|
||
for (const [modeKey, mode] of Object.entries(data.modes)) {
|
||
const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)';
|
||
const statusIcon = mode.ready ? '✓' : '✗';
|
||
|
||
html += `
|
||
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 15px; margin-bottom: 15px; border-left: 3px solid ${statusColor};">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||
<h3 style="margin: 0; color: var(--accent-cyan);">${mode.name}</h3>
|
||
<span style="color: ${statusColor}; font-weight: bold;">${statusIcon} ${mode.ready ? 'Ready' : 'Missing Required'}</span>
|
||
</div>
|
||
<div style="display: grid; gap: 8px;">
|
||
`;
|
||
|
||
for (const [toolName, tool] of Object.entries(mode.tools)) {
|
||
const installed = tool.installed;
|
||
const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)';
|
||
const requiredBadge = tool.required ? '<span style="background: var(--accent-orange); color: #000; padding: 1px 5px; border-radius: 3px; font-size: 9px; margin-left: 5px;">REQUIRED</span>' : '';
|
||
|
||
if (!installed) totalMissing++;
|
||
|
||
// Get install command for current OS
|
||
let installCmd = '';
|
||
if (tool.install) {
|
||
if (tool.install.pip) {
|
||
installCmd = tool.install.pip;
|
||
} else if (data.pkg_manager && tool.install[data.pkg_manager]) {
|
||
installCmd = tool.install[data.pkg_manager];
|
||
} else if (tool.install.manual) {
|
||
installCmd = tool.install.manual;
|
||
}
|
||
}
|
||
|
||
html += `
|
||
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; background: var(--bg-secondary); border-radius: 4px;">
|
||
<span style="color: ${dotColor}; font-size: 16px;">●</span>
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: bold;">${toolName}${requiredBadge}</div>
|
||
<div style="font-size: 11px; color: var(--text-dim);">${tool.description}</div>
|
||
</div>
|
||
${!installed && installCmd ? `
|
||
<code style="font-size: 10px; background: var(--bg-tertiary); padding: 4px 8px; border-radius: 3px; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${installCmd}">${installCmd}</code>
|
||
` : ''}
|
||
<span style="font-size: 11px; color: ${dotColor}; font-weight: bold;">${installed ? 'OK' : 'MISSING'}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
// Summary at top
|
||
const summaryHtml = `
|
||
<div style="background: ${totalMissing > 0 ? 'rgba(255, 100, 0, 0.1)' : 'rgba(0, 255, 100, 0.1)'}; border: 1px solid ${totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'}; border-radius: 8px; padding: 15px; margin-bottom: 20px;">
|
||
<div style="font-size: 16px; font-weight: bold; color: ${totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'};">
|
||
${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'}
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-dim); margin-top: 5px;">
|
||
OS: ${data.os} | Package Manager: ${data.pkg_manager}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
content.innerHTML = summaryHtml + html;
|
||
|
||
// Update button indicator
|
||
const btn = document.getElementById('depsBtn');
|
||
if (btn) {
|
||
btn.style.color = totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)';
|
||
}
|
||
})
|
||
.catch(err => {
|
||
content.innerHTML = '<div style="color: var(--accent-red);">Error loading dependencies: ' + err.message + '</div>';
|
||
});
|
||
}
|
||
|
||
// Check dependencies on page load
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
// Check if user dismissed the startup check
|
||
const dismissed = localStorage.getItem('depsCheckDismissed');
|
||
|
||
// Quick check for missing dependencies
|
||
fetch('/dependencies')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
let missingModes = 0;
|
||
let missingTools = [];
|
||
|
||
for (const [modeKey, mode] of Object.entries(data.modes)) {
|
||
if (!mode.ready) {
|
||
missingModes++;
|
||
mode.missing_required.forEach(tool => {
|
||
if (!missingTools.includes(tool)) {
|
||
missingTools.push(tool);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const btn = document.getElementById('depsBtn');
|
||
if (btn && missingModes > 0) {
|
||
btn.style.color = 'var(--accent-orange)';
|
||
btn.title = missingModes + ' mode(s) have missing tools - click to see details';
|
||
}
|
||
|
||
// Show startup prompt if tools are missing and not dismissed
|
||
// Only show if disclaimer has been accepted
|
||
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
|
||
showStartupDepsPrompt(missingModes, missingTools.length);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
function showStartupDepsPrompt(modeCount, toolCount) {
|
||
const notice = document.createElement('div');
|
||
notice.id = 'startupDepsModal';
|
||
notice.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
left: 20px;
|
||
z-index: 10000;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--accent-orange);
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 165, 0, 0.2);
|
||
max-width: 380px;
|
||
animation: slideIn 0.3s ease-out;
|
||
`;
|
||
notice.innerHTML = `
|
||
<style>
|
||
@keyframes slideIn {
|
||
from { transform: translateX(-100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
</style>
|
||
<div style="padding: 15px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<h3 style="margin: 0; color: var(--accent-orange); font-size: 14px; display: flex; align-items: center; gap: 8px;">
|
||
<span>🔧</span> Missing Dependencies
|
||
</h3>
|
||
<button onclick="closeStartupDeps()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 18px; padding: 0; line-height: 1;">×</button>
|
||
</div>
|
||
<p style="color: var(--text-secondary); margin: 0 0 15px 0; font-size: 13px; line-height: 1.4;">
|
||
<strong style="color: var(--accent-orange);">${modeCount} mode(s)</strong> require tools that aren't installed.
|
||
</p>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<button class="action-btn" onclick="closeStartupDeps(); showDependencies();" style="padding: 10px 16px; font-size: 12px;">
|
||
View Details & Install
|
||
</button>
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim); cursor: pointer;">
|
||
<input type="checkbox" id="dontShowAgain" style="cursor: pointer;">
|
||
Don't show again
|
||
</label>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(notice);
|
||
}
|
||
|
||
function closeStartupDeps() {
|
||
const modal = document.getElementById('startupDepsModal');
|
||
if (modal) {
|
||
if (document.getElementById('dontShowAgain')?.checked) {
|
||
localStorage.setItem('depsCheckDismissed', 'true');
|
||
}
|
||
modal.remove();
|
||
}
|
||
}
|
||
|
||
function logout(event) {
|
||
// We use event.currentTarget to ensure we select the button even if the icon was clicked
|
||
const btn = event.currentTarget;
|
||
|
||
// 1. Visual Feedback: Change color to red and add a "glow"
|
||
btn.style.color = "#ff4d4d";
|
||
btn.style.borderColor = "#ff4d4d";
|
||
btn.style.textShadow = "0 0 10px #ff4d4d"; // Glow effect
|
||
btn.style.transform = "scale(0.95)"; // Slight press effect
|
||
|
||
// 2. Disable the button to prevent double clicks
|
||
btn.style.pointerEvents = "none";
|
||
|
||
// 3. Logic execution
|
||
setTimeout(() => {
|
||
localStorage.removeItem('user_session');
|
||
window.location.href = '/login';
|
||
}, 600); // 600ms is enough for the user to perceive the color change
|
||
}
|
||
</script>
|
||
</body>
|
||
|
||
</html> |