mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
GPS geolocation only works on HTTPS or localhost. Added manual lat/lon input fields so users can enter their coordinates directly when accessing the app over HTTP on a local network. - Added latitude/longitude input fields to both ADS-B tab and dashboard - GPS button now checks for secure context before attempting geolocation - Shows helpful error message directing users to use manual input - Input fields update observer location and redraw range rings on change 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9897 lines
416 KiB
HTML
9897 lines
416 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 // Signal Intelligence</title>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<!-- Leaflet.js for aircraft 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>
|
||
<!-- Leaflet MarkerCluster for aircraft clustering -->
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css"/>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css"/>
|
||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Rajdhani:wght@400;500;600;700&display=swap');
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
:root {
|
||
--bg-primary: #000000;
|
||
--bg-secondary: #0a0a0a;
|
||
--bg-tertiary: #111111;
|
||
--bg-card: #0d0d0d;
|
||
--accent-cyan: #00d4ff;
|
||
--accent-cyan-dim: #00d4ff40;
|
||
--accent-green: #00ff88;
|
||
--accent-red: #ff3366;
|
||
--accent-orange: #ff8800;
|
||
--text-primary: #ffffff;
|
||
--text-secondary: #888888;
|
||
--text-dim: #444444;
|
||
--border-color: #1a1a1a;
|
||
--border-glow: #00d4ff33;
|
||
}
|
||
|
||
[data-theme="light"] {
|
||
--bg-primary: #f5f5f5;
|
||
--bg-secondary: #e8e8e8;
|
||
--bg-tertiary: #dddddd;
|
||
--bg-card: #ffffff;
|
||
--accent-cyan: #0088aa;
|
||
--accent-cyan-dim: #0088aa40;
|
||
--accent-green: #00aa55;
|
||
--accent-red: #cc2244;
|
||
--accent-orange: #cc6600;
|
||
--text-primary: #111111;
|
||
--text-secondary: #555555;
|
||
--text-dim: #999999;
|
||
--border-color: #cccccc;
|
||
--border-glow: #0088aa33;
|
||
}
|
||
|
||
[data-theme="light"] body {
|
||
background-image:
|
||
radial-gradient(ellipse at top, #d0e8f0 0%, transparent 50%),
|
||
radial-gradient(ellipse at bottom, #f0f0f0 0%, var(--bg-primary) 100%);
|
||
}
|
||
|
||
[data-theme="light"] .leaflet-tile-pane {
|
||
filter: none;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
min-height: 100vh;
|
||
background-image:
|
||
radial-gradient(ellipse at top, #001a2c 0%, transparent 50%),
|
||
radial-gradient(ellipse at bottom, #0a0a0a 0%, var(--bg-primary) 100%);
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
header {
|
||
background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%);
|
||
padding: 30px 20px;
|
||
text-align: center;
|
||
border-bottom: 1px solid var(--border-color);
|
||
margin-bottom: 25px;
|
||
position: relative;
|
||
}
|
||
|
||
header::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -1px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 200px;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||
}
|
||
|
||
header h1 {
|
||
color: var(--text-primary);
|
||
font-size: 2.5em;
|
||
font-weight: 700;
|
||
letter-spacing: 8px;
|
||
text-transform: uppercase;
|
||
margin-bottom: 8px;
|
||
text-shadow: 0 0 30px var(--accent-cyan-dim);
|
||
}
|
||
|
||
header p {
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
letter-spacing: 3px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.help-btn {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
|
||
.help-btn:hover {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--accent-cyan);
|
||
box-shadow: 0 0 15px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.theme-toggle {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 60px;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
|
||
.theme-toggle:hover {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--accent-cyan);
|
||
box-shadow: 0 0 15px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.theme-toggle .icon-sun,
|
||
.theme-toggle .icon-moon {
|
||
position: absolute;
|
||
transition: opacity 0.2s, transform 0.2s;
|
||
}
|
||
|
||
.theme-toggle .icon-sun {
|
||
opacity: 0;
|
||
transform: rotate(-90deg);
|
||
}
|
||
|
||
.theme-toggle .icon-moon {
|
||
opacity: 1;
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
[data-theme="light"] .theme-toggle .icon-sun {
|
||
opacity: 1;
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
[data-theme="light"] .theme-toggle .icon-moon {
|
||
opacity: 0;
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.help-modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.85);
|
||
z-index: 10000;
|
||
overflow-y: auto;
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
.help-modal.active {
|
||
display: block;
|
||
}
|
||
|
||
.help-content {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 30px;
|
||
position: relative;
|
||
}
|
||
|
||
.help-content h2 {
|
||
color: var(--accent-cyan);
|
||
margin-bottom: 20px;
|
||
font-size: 24px;
|
||
letter-spacing: 2px;
|
||
}
|
||
|
||
.help-content h3 {
|
||
color: var(--text-primary);
|
||
margin: 25px 0 15px 0;
|
||
font-size: 14px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.help-close {
|
||
position: absolute;
|
||
top: 15px;
|
||
right: 15px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.help-close:hover {
|
||
color: var(--accent-red);
|
||
}
|
||
|
||
.icon-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
margin: 15px 0;
|
||
}
|
||
|
||
.icon-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.icon-item .icon {
|
||
font-size: 18px;
|
||
width: 30px;
|
||
text-align: center;
|
||
}
|
||
|
||
.icon-item .desc {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.tip-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 15px 0;
|
||
}
|
||
|
||
.tip-list li {
|
||
padding: 8px 0;
|
||
padding-left: 20px;
|
||
position: relative;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.tip-list li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.tip-list li::before {
|
||
content: '›';
|
||
position: absolute;
|
||
left: 0;
|
||
color: var(--accent-cyan);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.help-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
margin-bottom: 20px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.help-tab {
|
||
flex: 1;
|
||
padding: 10px;
|
||
background: var(--bg-primary);
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.help-tab:not(:last-child) {
|
||
border-right: 1px solid var(--border-color);
|
||
}
|
||
|
||
.help-tab:hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.help-tab.active {
|
||
background: var(--accent-cyan);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
.help-section {
|
||
display: none;
|
||
}
|
||
|
||
.help-section.active {
|
||
display: block;
|
||
}
|
||
|
||
.logo {
|
||
margin-bottom: 15px;
|
||
animation: logo-pulse 3s ease-in-out infinite;
|
||
}
|
||
|
||
.logo svg {
|
||
filter: drop-shadow(0 0 10px var(--accent-cyan-dim));
|
||
}
|
||
|
||
@keyframes logo-pulse {
|
||
0%, 100% {
|
||
filter: drop-shadow(0 0 5px var(--accent-cyan-dim));
|
||
}
|
||
50% {
|
||
filter: drop-shadow(0 0 20px var(--accent-cyan));
|
||
}
|
||
}
|
||
|
||
.main-content {
|
||
display: grid;
|
||
grid-template-columns: 340px 1fr;
|
||
gap: 25px;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.main-content {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.sidebar {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
padding: 20px;
|
||
position: relative;
|
||
}
|
||
|
||
.sidebar::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, var(--accent-cyan), transparent);
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.section h3 {
|
||
color: var(--accent-cyan);
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.section h3::before {
|
||
content: '▼';
|
||
font-size: 8px;
|
||
color: var(--text-dim);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.section.collapsed h3::before {
|
||
transform: rotate(-90deg);
|
||
}
|
||
|
||
.section.collapsed > *:not(h3) {
|
||
display: none !important;
|
||
}
|
||
|
||
.section h3:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.section h3:hover::before {
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
color: var(--text-secondary);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-primary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.form-group input:focus,
|
||
.form-group select:focus {
|
||
outline: none;
|
||
border-color: var(--accent-cyan);
|
||
box-shadow: 0 0 15px var(--accent-cyan-dim), inset 0 0 15px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.checkbox-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.checkbox-group label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
padding: 8px 12px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.checkbox-group label:hover {
|
||
border-color: var(--accent-cyan);
|
||
}
|
||
|
||
.checkbox-group input[type="checkbox"] {
|
||
width: auto;
|
||
accent-color: var(--accent-cyan);
|
||
}
|
||
|
||
.preset-buttons {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.preset-btn {
|
||
padding: 8px 14px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
transition: all 0.2s ease;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.preset-btn:hover {
|
||
background: var(--accent-cyan);
|
||
color: var(--bg-primary);
|
||
border-color: var(--accent-cyan);
|
||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.run-btn {
|
||
width: 100%;
|
||
padding: 14px;
|
||
background: transparent;
|
||
border: 2px solid var(--accent-green);
|
||
color: var(--accent-green);
|
||
font-family: 'Rajdhani', sans-serif;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 3px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
margin-top: 12px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.run-btn::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
||
opacity: 0.3;
|
||
transition: left 0.5s ease;
|
||
}
|
||
|
||
.run-btn:hover {
|
||
background: var(--accent-green);
|
||
color: var(--bg-primary);
|
||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
|
||
}
|
||
|
||
.run-btn:hover::before {
|
||
left: 100%;
|
||
}
|
||
|
||
.stop-btn {
|
||
width: 100%;
|
||
padding: 16px;
|
||
background: transparent;
|
||
border: 2px solid var(--accent-red);
|
||
color: var(--accent-red);
|
||
font-family: 'Rajdhani', sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.stop-btn:hover {
|
||
background: var(--accent-red);
|
||
color: var(--bg-primary);
|
||
box-shadow: 0 0 30px rgba(255, 51, 102, 0.4);
|
||
}
|
||
|
||
.output-panel {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
.output-panel::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||
}
|
||
|
||
.output-header {
|
||
padding: 18px 25px;
|
||
background: var(--bg-secondary);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.output-header h3 {
|
||
color: var(--text-primary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 3px;
|
||
}
|
||
|
||
.stats {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.stats > div {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.stats > div:hover {
|
||
border-color: var(--accent-cyan);
|
||
}
|
||
|
||
.stats span {
|
||
color: var(--accent-cyan);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.output-content {
|
||
flex: 1;
|
||
padding: 15px;
|
||
overflow-y: auto;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
background: var(--bg-primary);
|
||
margin: 15px;
|
||
border: 1px solid var(--border-color);
|
||
min-height: 500px;
|
||
max-height: 600px;
|
||
}
|
||
|
||
.output-content::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.output-content::-webkit-scrollbar-track {
|
||
background: var(--bg-primary);
|
||
}
|
||
|
||
.output-content::-webkit-scrollbar-thumb {
|
||
background: var(--border-color);
|
||
}
|
||
|
||
.output-content::-webkit-scrollbar-thumb:hover {
|
||
background: var(--accent-cyan);
|
||
}
|
||
|
||
.message {
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
border: 1px solid var(--border-color);
|
||
border-left: 3px solid var(--accent-cyan);
|
||
background: var(--bg-secondary);
|
||
position: relative;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.message:hover {
|
||
border-left-color: var(--accent-cyan);
|
||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.message.pocsag {
|
||
border-left-color: var(--accent-cyan);
|
||
}
|
||
|
||
.message.flex {
|
||
border-left-color: var(--accent-orange);
|
||
}
|
||
|
||
.message .header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.message .protocol {
|
||
color: var(--accent-cyan);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.message.pocsag .protocol {
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.message.flex .protocol {
|
||
color: var(--accent-orange);
|
||
}
|
||
|
||
.message .address {
|
||
color: var(--accent-green);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.message .content {
|
||
color: var(--text-primary);
|
||
word-wrap: break-word;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.message .content.numeric {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 15px;
|
||
letter-spacing: 2px;
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.status-bar {
|
||
padding: 15px 25px;
|
||
background: var(--bg-secondary);
|
||
border-top: 1px solid var(--border-color);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
background: var(--text-dim);
|
||
position: relative;
|
||
}
|
||
|
||
.status-dot.running {
|
||
background: var(--accent-green);
|
||
box-shadow: 0 0 10px var(--accent-green);
|
||
animation: pulse-glow 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse-glow {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
box-shadow: 0 0 10px var(--accent-green);
|
||
}
|
||
50% {
|
||
opacity: 0.7;
|
||
box-shadow: 0 0 20px var(--accent-green), 0 0 30px var(--accent-green);
|
||
}
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
|
||
.clear-btn {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.clear-btn:hover {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.tool-status {
|
||
font-size: 10px;
|
||
padding: 4px 10px;
|
||
margin-left: 8px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tool-status.ok {
|
||
background: transparent;
|
||
border: 1px solid var(--accent-green);
|
||
color: var(--accent-green);
|
||
}
|
||
|
||
.tool-status.missing {
|
||
background: transparent;
|
||
border: 1px solid var(--accent-red);
|
||
color: var(--accent-red);
|
||
}
|
||
|
||
.info-text {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
margin-top: 8px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.header-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.signal-meter {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
height: 20px;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.signal-bar {
|
||
width: 4px;
|
||
background: var(--border-color);
|
||
transition: all 0.1s ease;
|
||
}
|
||
|
||
.signal-bar:nth-child(1) { height: 4px; }
|
||
.signal-bar:nth-child(2) { height: 8px; }
|
||
.signal-bar:nth-child(3) { height: 12px; }
|
||
.signal-bar:nth-child(4) { height: 16px; }
|
||
.signal-bar:nth-child(5) { height: 20px; }
|
||
|
||
.signal-bar.active {
|
||
background: var(--accent-cyan);
|
||
box-shadow: 0 0 8px var(--accent-cyan);
|
||
}
|
||
|
||
.waterfall-container {
|
||
padding: 0 15px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
#waterfallCanvas {
|
||
width: 100%;
|
||
height: 60px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
transition: box-shadow 0.3s ease;
|
||
}
|
||
|
||
#waterfallCanvas.active {
|
||
box-shadow: 0 0 15px var(--accent-cyan-dim);
|
||
border-color: var(--accent-cyan);
|
||
}
|
||
|
||
.status-controls {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.control-btn {
|
||
padding: 6px 12px;
|
||
background: transparent;
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
transition: all 0.2s ease;
|
||
font-family: 'Rajdhani', sans-serif;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.control-btn.active {
|
||
border-color: var(--accent-green);
|
||
color: var(--accent-green);
|
||
}
|
||
|
||
.control-btn.muted {
|
||
border-color: var(--accent-red);
|
||
color: var(--accent-red);
|
||
}
|
||
|
||
/* Signal Strength Graph */
|
||
.signal-graph-panel {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: 12px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.signal-graph-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.signal-graph-header h4 {
|
||
color: var(--accent-cyan);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin: 0;
|
||
}
|
||
|
||
.signal-graph-device {
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
#signalGraph {
|
||
width: 100%;
|
||
height: 80px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Network Relationship Graph */
|
||
.network-graph-container {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: 12px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.network-graph-container h4 {
|
||
color: var(--accent-cyan);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin: 0 0 10px 0;
|
||
}
|
||
|
||
#networkGraph {
|
||
width: 100%;
|
||
height: 200px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.network-graph-legend {
|
||
display: flex;
|
||
gap: 15px;
|
||
margin-top: 8px;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.legend-dot.ap { background: var(--accent-cyan); }
|
||
.legend-dot.client { background: var(--accent-green); }
|
||
.legend-dot.drone { background: var(--accent-orange); }
|
||
|
||
/* Channel Recommendation */
|
||
.channel-recommendation {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--accent-green);
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.channel-recommendation h4 {
|
||
color: var(--accent-green);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.channel-recommendation .rec-text {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.channel-recommendation .rec-channel {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: var(--accent-green);
|
||
}
|
||
|
||
/* Device Correlation */
|
||
.correlation-badge {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
background: var(--accent-orange);
|
||
color: var(--bg-primary);
|
||
font-size: 9px;
|
||
border-radius: 3px;
|
||
margin-left: 5px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Hidden SSID reveal */
|
||
.hidden-ssid-revealed {
|
||
color: var(--accent-orange);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Mode tabs - grouped layout */
|
||
.mode-tabs-container {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.tab-group {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.tab-group-label {
|
||
font-size: 9px;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
margin-bottom: 4px;
|
||
padding-left: 4px;
|
||
font-family: 'Rajdhani', sans-serif;
|
||
}
|
||
|
||
.mode-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mode-tab {
|
||
flex: 1;
|
||
padding: 10px 8px;
|
||
background: var(--bg-primary);
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-family: 'Rajdhani', sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.mode-tab .tab-icon {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.mode-tab:not(:last-child) {
|
||
border-right: 1px solid var(--border-color);
|
||
}
|
||
|
||
.mode-tab:hover {
|
||
background: var(--bg-secondary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.mode-tab.active {
|
||
background: var(--accent-cyan);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
.mode-content {
|
||
display: none;
|
||
}
|
||
|
||
.mode-content.active {
|
||
display: block;
|
||
}
|
||
|
||
/* Aircraft (ADS-B) Styles */
|
||
.aircraft-card {
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
border: 1px solid var(--border-color);
|
||
border-left: 3px solid var(--accent-cyan);
|
||
background: var(--bg-secondary);
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.aircraft-icon {
|
||
font-size: 28px;
|
||
transform: rotate(var(--heading, 0deg));
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.aircraft-info {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.aircraft-callsign {
|
||
color: var(--accent-cyan);
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.aircraft-data {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.aircraft-data span {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Aircraft Map Display - Leaflet */
|
||
.aircraft-map-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 400px;
|
||
background: #0a0a0a;
|
||
border: 1px solid var(--accent-cyan);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.2);
|
||
}
|
||
|
||
#aircraftMap {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #0a0a0a;
|
||
}
|
||
|
||
/* Dark theme for Leaflet */
|
||
.leaflet-container {
|
||
background: #0a0a0a;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.leaflet-tile-pane {
|
||
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2);
|
||
}
|
||
|
||
.leaflet-control-zoom {
|
||
margin-top: 45px !important;
|
||
}
|
||
|
||
.leaflet-control-zoom a {
|
||
background: var(--bg-card) !important;
|
||
color: var(--accent-cyan) !important;
|
||
border-color: var(--border-color) !important;
|
||
}
|
||
|
||
.leaflet-control-zoom a:hover {
|
||
background: var(--bg-tertiary) !important;
|
||
}
|
||
|
||
.leaflet-control-attribution {
|
||
background: rgba(0, 0, 0, 0.7) !important;
|
||
color: #666 !important;
|
||
font-size: 9px !important;
|
||
}
|
||
|
||
.leaflet-control-attribution a {
|
||
color: #888 !important;
|
||
}
|
||
|
||
.map-header {
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 10px;
|
||
right: 10px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
z-index: 1000;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--accent-cyan);
|
||
text-shadow: 0 0 5px var(--accent-cyan);
|
||
background: rgba(0, 0, 0, 0.6);
|
||
padding: 4px 8px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.map-footer {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
left: 10px;
|
||
right: 10px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
z-index: 1000;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 10px;
|
||
color: var(--accent-cyan);
|
||
text-shadow: 0 0 5px var(--accent-cyan);
|
||
background: rgba(0, 0, 0, 0.6);
|
||
padding: 4px 8px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Aircraft marker styles */
|
||
.aircraft-marker {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.aircraft-marker svg {
|
||
filter: drop-shadow(0 0 4px currentColor);
|
||
}
|
||
|
||
.aircraft-popup {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.aircraft-popup .callsign {
|
||
color: var(--accent-cyan);
|
||
font-weight: bold;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.aircraft-popup .data-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin: 3px 0;
|
||
}
|
||
|
||
.aircraft-popup .label {
|
||
color: #888;
|
||
}
|
||
|
||
.aircraft-popup .value {
|
||
color: #fff;
|
||
}
|
||
|
||
.leaflet-popup-content-wrapper {
|
||
background: var(--bg-card) !important;
|
||
border: 1px solid var(--border-color) !important;
|
||
border-radius: 4px !important;
|
||
}
|
||
|
||
.leaflet-popup-tip {
|
||
background: var(--bg-card) !important;
|
||
border: 1px solid var(--border-color) !important;
|
||
}
|
||
|
||
.leaflet-popup-content {
|
||
color: var(--text-primary) !important;
|
||
margin: 10px !important;
|
||
}
|
||
|
||
.leaflet-tooltip.aircraft-tooltip {
|
||
background: rgba(0, 0, 0, 0.8) !important;
|
||
border: 1px solid var(--accent-cyan) !important;
|
||
color: var(--accent-cyan) !important;
|
||
font-family: 'JetBrains Mono', monospace !important;
|
||
font-size: 10px !important;
|
||
padding: 2px 6px !important;
|
||
border-radius: 2px !important;
|
||
}
|
||
|
||
.leaflet-tooltip.aircraft-tooltip::before {
|
||
border-right-color: var(--accent-cyan) !important;
|
||
}
|
||
|
||
/* Satellite Mode Styles */
|
||
.satellite-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.satellite-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.satellite-tab {
|
||
padding: 8px 16px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-family: 'Rajdhani', sans-serif;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.satellite-tab:hover {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.satellite-tab.active {
|
||
background: var(--accent-cyan);
|
||
border-color: var(--accent-cyan);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
.satellite-content {
|
||
display: none;
|
||
}
|
||
|
||
.satellite-content.active {
|
||
display: block;
|
||
}
|
||
|
||
/* Satellite Pass Predictor - Cool UI */
|
||
.pass-predictor {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
grid-template-rows: auto auto;
|
||
gap: 15px;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.pass-predictor {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.polar-plot-container {
|
||
position: relative;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
min-height: 320px;
|
||
}
|
||
|
||
.ground-track-cell {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
min-height: 320px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.ground-track-cell #groundTrackMap {
|
||
flex: 1;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.countdown-cell {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
}
|
||
|
||
.countdown-cell .satellite-countdown {
|
||
margin: 0;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
}
|
||
|
||
.pass-list-cell {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
max-height: 350px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.polar-plot-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.polar-plot-title {
|
||
color: var(--accent-cyan);
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.popout-btn {
|
||
padding: 4px 10px;
|
||
background: transparent;
|
||
border: 1px solid var(--accent-cyan);
|
||
border-radius: 3px;
|
||
color: var(--accent-cyan);
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.popout-btn:hover {
|
||
background: var(--accent-cyan);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
.polar-plot {
|
||
position: relative;
|
||
width: 100%;
|
||
padding-bottom: 100%;
|
||
}
|
||
|
||
.polar-plot canvas {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.pass-list-container {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
max-height: 450px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.pass-list-header {
|
||
color: var(--accent-cyan);
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
/* Satellite Countdown Block */
|
||
.satellite-countdown {
|
||
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||
border: 1px solid var(--accent-cyan);
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
margin-bottom: 15px;
|
||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.1);
|
||
}
|
||
|
||
.countdown-satellite-name {
|
||
color: var(--accent-cyan);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
text-align: center;
|
||
margin-bottom: 12px;
|
||
text-shadow: 0 0 10px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.countdown-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.countdown-block {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.countdown-label {
|
||
color: var(--text-dim);
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.countdown-value {
|
||
color: var(--accent-cyan);
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
text-shadow: 0 0 15px var(--accent-cyan-dim);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.countdown-value.active {
|
||
color: var(--accent-green);
|
||
text-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
|
||
animation: countdown-pulse 1s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes countdown-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.7; }
|
||
}
|
||
|
||
.countdown-sublabel {
|
||
color: var(--text-secondary);
|
||
font-size: 9px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.countdown-status {
|
||
text-align: center;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
padding-top: 8px;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
.countdown-status.visible {
|
||
color: var(--accent-green);
|
||
}
|
||
|
||
.countdown-status.upcoming {
|
||
color: var(--accent-orange);
|
||
}
|
||
|
||
.location-input {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.location-input input {
|
||
flex: 1;
|
||
padding: 8px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 3px;
|
||
color: var(--text-primary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.pass-card {
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.pass-card:hover {
|
||
border-color: var(--accent-cyan);
|
||
}
|
||
|
||
.pass-card.active {
|
||
border-color: var(--accent-cyan);
|
||
box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
|
||
}
|
||
|
||
.pass-satellite {
|
||
color: var(--accent-cyan);
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.pass-time {
|
||
color: var(--text-primary);
|
||
font-size: 13px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.pass-details {
|
||
display: flex;
|
||
gap: 15px;
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.pass-details span {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.pass-quality {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pass-quality.excellent {
|
||
background: rgba(0, 255, 0, 0.2);
|
||
color: var(--accent-green);
|
||
}
|
||
|
||
.pass-quality.good {
|
||
background: rgba(0, 255, 255, 0.2);
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.pass-quality.fair {
|
||
background: rgba(255, 102, 0, 0.2);
|
||
color: var(--accent-orange);
|
||
}
|
||
|
||
/* Satellite List Styles */
|
||
.satellite-list {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.sat-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px 8px;
|
||
margin-bottom: 4px;
|
||
background: var(--bg-secondary);
|
||
border-radius: 3px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.sat-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.sat-item label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
flex: 1;
|
||
}
|
||
|
||
.sat-item input[type="checkbox"] {
|
||
margin: 0;
|
||
}
|
||
|
||
.sat-item .sat-name {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.sat-item .sat-norad {
|
||
color: var(--text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.sat-item .sat-remove {
|
||
background: none;
|
||
border: none;
|
||
color: var(--accent-red);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
padding: 2px 6px;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.sat-item .sat-remove:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
.sat-item.builtin .sat-remove {
|
||
display: none;
|
||
}
|
||
|
||
/* Satellite Add Modal */
|
||
.sat-modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 10000;
|
||
}
|
||
|
||
.sat-modal.active {
|
||
display: flex;
|
||
}
|
||
|
||
.sat-modal-content {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--accent-cyan);
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
width: 90%;
|
||
max-width: 500px;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sat-modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.sat-modal-header h3 {
|
||
color: var(--accent-cyan);
|
||
margin: 0;
|
||
}
|
||
|
||
.sat-modal-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.sat-modal-close:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.sat-modal-tabs {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.sat-modal-tab {
|
||
flex: 1;
|
||
padding: 8px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.sat-modal-tab.active {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.sat-modal-section {
|
||
display: none;
|
||
}
|
||
|
||
.sat-modal-section.active {
|
||
display: block;
|
||
}
|
||
|
||
.tle-textarea {
|
||
width: 100%;
|
||
height: 120px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
color: var(--text-primary);
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
padding: 10px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.celestrak-categories {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 8px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.celestrak-cat {
|
||
padding: 8px 12px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
}
|
||
|
||
.celestrak-cat:hover {
|
||
border-color: var(--accent-cyan);
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
/* Iridium Burst Styles */
|
||
.iridium-warning {
|
||
padding: 12px;
|
||
margin-bottom: 15px;
|
||
background: rgba(255, 102, 0, 0.1);
|
||
border: 1px solid var(--accent-orange);
|
||
border-radius: 4px;
|
||
color: var(--accent-orange);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.iridium-warning strong {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.burst-card {
|
||
padding: 10px;
|
||
margin-bottom: 6px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-left: 3px solid #9370DB;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.burst-time {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.burst-freq {
|
||
color: #9370DB;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.burst-data {
|
||
color: var(--text-primary);
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* Popout window styles */
|
||
.popout-container {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: var(--bg-primary);
|
||
z-index: 10000;
|
||
display: none;
|
||
}
|
||
|
||
.popout-container.active {
|
||
display: block;
|
||
}
|
||
|
||
.popout-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15px 20px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.popout-title {
|
||
color: var(--accent-cyan);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.popout-close {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: 1px solid var(--accent-red);
|
||
border-radius: 3px;
|
||
color: var(--accent-red);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.popout-body {
|
||
padding: 20px;
|
||
height: calc(100vh - 70px);
|
||
overflow: auto;
|
||
}
|
||
|
||
/* Sensor card styling */
|
||
.sensor-card {
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
border: 1px solid var(--border-color);
|
||
border-left: 3px solid var(--accent-green);
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.sensor-card .device-name {
|
||
color: var(--accent-green);
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.sensor-card .sensor-data {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.sensor-card .data-item {
|
||
background: var(--bg-primary);
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
.sensor-card .data-label {
|
||
font-size: 9px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.sensor-card .data-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 14px;
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
/* Recon Dashboard - Prominent Device Intelligence */
|
||
.recon-panel {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
margin: 15px;
|
||
margin-bottom: 10px;
|
||
position: relative;
|
||
}
|
||
|
||
.recon-panel::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, var(--accent-orange), var(--accent-cyan), transparent);
|
||
}
|
||
|
||
.recon-panel.collapsed .recon-content {
|
||
display: none;
|
||
}
|
||
|
||
.recon-header {
|
||
padding: 12px 15px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.recon-header h4 {
|
||
color: var(--accent-orange);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
margin: 0;
|
||
}
|
||
|
||
.recon-stats {
|
||
display: flex;
|
||
gap: 15px;
|
||
font-size: 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.recon-stats span {
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.recon-content {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.device-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto auto auto;
|
||
gap: 10px;
|
||
padding: 10px 15px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
font-size: 11px;
|
||
align-items: center;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.device-row:hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.device-row.anomaly {
|
||
border-left: 3px solid var(--accent-red);
|
||
background: rgba(255, 51, 102, 0.05);
|
||
}
|
||
|
||
.device-row.new-device {
|
||
border-left: 3px solid var(--accent-green);
|
||
background: rgba(0, 255, 136, 0.05);
|
||
}
|
||
|
||
.device-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.device-name-row {
|
||
color: var(--text-primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.device-id {
|
||
color: var(--text-dim);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.device-meta {
|
||
text-align: right;
|
||
color: var(--text-secondary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.device-meta.encrypted {
|
||
color: var(--accent-green);
|
||
}
|
||
|
||
.device-meta.plaintext {
|
||
color: var(--accent-red);
|
||
}
|
||
|
||
.transmission-bar {
|
||
width: 60px;
|
||
height: 4px;
|
||
background: var(--border-color);
|
||
position: relative;
|
||
}
|
||
|
||
.transmission-bar-fill {
|
||
height: 100%;
|
||
background: var(--accent-cyan);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
border: 1px solid;
|
||
}
|
||
|
||
.badge.proto-pocsag { border-color: var(--accent-cyan); color: var(--accent-cyan); }
|
||
.badge.proto-flex { border-color: var(--accent-orange); color: var(--accent-orange); }
|
||
.badge.proto-433 { border-color: var(--accent-green); color: var(--accent-green); }
|
||
.badge.proto-unknown { border-color: var(--text-dim); color: var(--text-dim); }
|
||
|
||
.recon-toggle {
|
||
padding: 4px 8px;
|
||
background: transparent;
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.recon-toggle:hover {
|
||
border-color: var(--accent-orange);
|
||
color: var(--accent-orange);
|
||
}
|
||
|
||
.recon-toggle.active {
|
||
border-color: var(--accent-orange);
|
||
color: var(--accent-orange);
|
||
background: rgba(255, 136, 0, 0.1);
|
||
}
|
||
|
||
.hex-dump {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
background: var(--bg-primary);
|
||
padding: 8px;
|
||
margin-top: 8px;
|
||
border: 1px solid var(--border-color);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.timeline-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent-cyan);
|
||
display: inline-block;
|
||
margin-right: 5px;
|
||
}
|
||
|
||
.timeline-dot.recent { background: var(--accent-green); }
|
||
.timeline-dot.stale { background: var(--accent-orange); }
|
||
.timeline-dot.old { background: var(--text-dim); }
|
||
|
||
/* WiFi Visualizations */
|
||
.wifi-visuals {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
padding: 15px;
|
||
background: var(--bg-secondary);
|
||
margin: 0 15px 10px 15px;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.wifi-visuals { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.wifi-visual-panel {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-color);
|
||
padding: 10px;
|
||
position: relative;
|
||
}
|
||
|
||
.wifi-visual-panel h5 {
|
||
color: var(--accent-cyan);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 5px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
/* Radar Display */
|
||
.radar-container {
|
||
position: relative;
|
||
width: 150px;
|
||
height: 150px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
#radarCanvas, #btRadarCanvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, #001515 0%, #000a0a 100%);
|
||
border: 1px solid var(--accent-cyan-dim);
|
||
}
|
||
|
||
#btRadarCanvas {
|
||
background: radial-gradient(circle, #150015 0%, #0a000a 100%);
|
||
border: 1px solid rgba(138, 43, 226, 0.3);
|
||
}
|
||
|
||
/* Channel Graph */
|
||
.channel-graph {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-around;
|
||
height: 60px;
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.channel-bar-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.channel-bar {
|
||
width: 80%;
|
||
background: var(--border-color);
|
||
min-height: 2px;
|
||
transition: height 0.3s ease, background 0.3s ease;
|
||
}
|
||
|
||
.channel-bar.active {
|
||
background: var(--accent-cyan);
|
||
box-shadow: 0 0 5px var(--accent-cyan);
|
||
}
|
||
|
||
.channel-bar.congested {
|
||
background: var(--accent-orange);
|
||
}
|
||
|
||
.channel-bar.very-congested {
|
||
background: var(--accent-red);
|
||
}
|
||
|
||
.channel-label {
|
||
font-size: 8px;
|
||
color: #fff;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
/* Security Donut */
|
||
.security-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.security-donut {
|
||
width: 80px;
|
||
height: 80px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#securityCanvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.security-legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.security-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.security-legend-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.security-legend-dot.wpa3 { background: var(--accent-green); }
|
||
.security-legend-dot.wpa2 { background: var(--accent-orange); }
|
||
.security-legend-dot.wep { background: var(--accent-red); }
|
||
.security-legend-dot.open { background: var(--accent-cyan); }
|
||
|
||
/* Signal Strength Meter */
|
||
.signal-strength-display {
|
||
text-align: center;
|
||
padding: 5px;
|
||
}
|
||
|
||
.target-ssid {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 5px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.signal-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 28px;
|
||
color: var(--accent-cyan);
|
||
text-shadow: 0 0 10px var(--accent-cyan-dim);
|
||
}
|
||
|
||
.signal-value.weak { color: var(--accent-red); text-shadow: 0 0 10px rgba(255,51,102,0.4); }
|
||
.signal-value.medium { color: var(--accent-orange); text-shadow: 0 0 10px rgba(255,136,0,0.4); }
|
||
.signal-value.strong { color: var(--accent-green); text-shadow: 0 0 10px rgba(0,255,136,0.4); }
|
||
|
||
.signal-bars-large {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-end;
|
||
gap: 3px;
|
||
height: 30px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.signal-bar-large {
|
||
width: 8px;
|
||
background: var(--border-color);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.signal-bar-large.active {
|
||
box-shadow: 0 0 5px currentColor;
|
||
}
|
||
|
||
.signal-bar-large.weak { background: var(--accent-red); }
|
||
.signal-bar-large.medium { background: var(--accent-orange); }
|
||
.signal-bar-large.strong { background: var(--accent-green); }
|
||
|
||
.signal-bar-large:nth-child(1) { height: 20%; }
|
||
.signal-bar-large:nth-child(2) { height: 40%; }
|
||
.signal-bar-large:nth-child(3) { height: 60%; }
|
||
.signal-bar-large:nth-child(4) { height: 80%; }
|
||
.signal-bar-large:nth-child(5) { height: 100%; }
|
||
|
||
/* Scanline effect overlay */
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
background: repeating-linear-gradient(
|
||
0deg,
|
||
rgba(0, 0, 0, 0.03),
|
||
rgba(0, 0, 0, 0.03) 1px,
|
||
transparent 1px,
|
||
transparent 2px
|
||
);
|
||
z-index: 1000;
|
||
}
|
||
|
||
/* Disclaimer Modal */
|
||
.disclaimer-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.95);
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.disclaimer-modal {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--accent-cyan);
|
||
max-width: 550px;
|
||
padding: 30px;
|
||
text-align: center;
|
||
box-shadow: 0 0 50px rgba(0, 212, 255, 0.3);
|
||
}
|
||
|
||
.disclaimer-modal h2 {
|
||
color: var(--accent-red);
|
||
font-size: 1.5em;
|
||
margin-bottom: 20px;
|
||
letter-spacing: 3px;
|
||
}
|
||
|
||
.disclaimer-modal .warning-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.disclaimer-modal p {
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
margin-bottom: 15px;
|
||
text-align: left;
|
||
}
|
||
|
||
.disclaimer-modal ul {
|
||
text-align: left;
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
margin: 15px 0;
|
||
padding-left: 20px;
|
||
}
|
||
|
||
.disclaimer-modal ul li {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.disclaimer-modal .accept-btn {
|
||
background: var(--accent-cyan);
|
||
color: #000;
|
||
border: none;
|
||
padding: 12px 40px;
|
||
font-family: 'Rajdhani', sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
letter-spacing: 2px;
|
||
cursor: pointer;
|
||
margin-top: 20px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.disclaimer-modal .accept-btn:hover {
|
||
background: #fff;
|
||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||
}
|
||
|
||
.disclaimer-hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Ground Track Map */
|
||
#groundTrackMap {
|
||
height: 240px;
|
||
border-radius: 6px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.ground-track-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.ground-track-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--accent-cyan);
|
||
}
|
||
|
||
.sat-position-indicator {
|
||
position: absolute;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #ff0;
|
||
border: 2px solid #000;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 100;
|
||
animation: pulse-sat 1s infinite;
|
||
}
|
||
|
||
@keyframes pulse-sat {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 255, 0, 0.7); }
|
||
50% { box-shadow: 0 0 0 8px rgba(255, 255, 0, 0); }
|
||
}
|
||
|
||
/* Beacon Flood Alert */
|
||
.beacon-flood-alert {
|
||
background: linear-gradient(135deg, rgba(255, 0, 0, 0.2), rgba(255, 100, 0, 0.2));
|
||
border: 1px solid #ff4444;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
margin: 10px 0;
|
||
animation: beacon-flash 0.5s infinite alternate;
|
||
}
|
||
|
||
@keyframes beacon-flash {
|
||
from { opacity: 0.8; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
/* WPS Indicator */
|
||
.wps-enabled {
|
||
background: #ff6600;
|
||
color: #000;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 9px;
|
||
font-weight: bold;
|
||
margin-left: 5px;
|
||
}
|
||
|
||
/* PMKID Capture */
|
||
.pmkid-btn {
|
||
background: linear-gradient(135deg, #9933ff, #6600cc);
|
||
color: #fff;
|
||
}
|
||
|
||
.pmkid-btn:hover {
|
||
background: linear-gradient(135deg, #aa44ff, #7700dd);
|
||
}
|
||
|
||
/* Find My Detection */
|
||
.findmy-device {
|
||
border-left: 3px solid #007aff;
|
||
background: rgba(0, 122, 255, 0.1);
|
||
}
|
||
|
||
.findmy-badge {
|
||
background: linear-gradient(135deg, #007aff, #5856d6);
|
||
color: #fff;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Tracker Following Alert */
|
||
.tracker-following-alert {
|
||
background: linear-gradient(135deg, rgba(255, 0, 0, 0.3), rgba(255, 50, 50, 0.2));
|
||
border: 2px solid #ff0000;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
margin: 10px 0;
|
||
animation: tracker-pulse 1s infinite;
|
||
}
|
||
|
||
@keyframes tracker-pulse {
|
||
0%, 100% { border-color: #ff0000; }
|
||
50% { border-color: #ff6666; }
|
||
}
|
||
|
||
.tracker-following-alert h4 {
|
||
color: #ff4444;
|
||
margin: 0 0 10px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Flight Path Trails */
|
||
.flight-trail {
|
||
stroke-dasharray: 5, 5;
|
||
fill: none;
|
||
}
|
||
|
||
/* Squawk Alerts */
|
||
.squawk-emergency {
|
||
background: #ff0000 !important;
|
||
animation: squawk-flash 0.3s infinite alternate;
|
||
}
|
||
|
||
.squawk-hijack {
|
||
background: #ff0000 !important;
|
||
}
|
||
|
||
.squawk-radio-fail {
|
||
background: #ff6600 !important;
|
||
}
|
||
|
||
.squawk-mayday {
|
||
background: #ff0000 !important;
|
||
}
|
||
|
||
@keyframes squawk-flash {
|
||
from { opacity: 0.7; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.squawk-alert-banner {
|
||
position: fixed;
|
||
top: 60px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #ff0000;
|
||
color: #fff;
|
||
padding: 15px 30px;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
z-index: 9999;
|
||
animation: squawk-banner-flash 0.5s infinite alternate;
|
||
}
|
||
|
||
@keyframes squawk-banner-flash {
|
||
from { background: #ff0000; }
|
||
to { background: #cc0000; }
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
|
||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
}
|
||
|
||
@keyframes fadeOut {
|
||
from { opacity: 1; }
|
||
to { opacity: 0; }
|
||
}
|
||
|
||
/* Military Aircraft */
|
||
.military-aircraft {
|
||
border-left: 3px solid #556b2f;
|
||
background: rgba(85, 107, 47, 0.2);
|
||
}
|
||
|
||
.military-badge {
|
||
background: #556b2f;
|
||
color: #fff;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 9px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Map Clustering */
|
||
.marker-cluster {
|
||
background: rgba(0, 212, 255, 0.6);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
color: #000;
|
||
border: 2px solid var(--accent-cyan);
|
||
}
|
||
|
||
.marker-cluster-small {
|
||
width: 30px;
|
||
height: 30px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.marker-cluster-medium {
|
||
width: 40px;
|
||
height: 40px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.marker-cluster-large {
|
||
width: 50px;
|
||
height: 50px;
|
||
font-size: 16px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- Disclaimer Modal -->
|
||
<div class="disclaimer-overlay" id="disclaimerModal">
|
||
<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>
|
||
|
||
<!-- 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>
|
||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
||
<span class="icon-moon">🌙</span>
|
||
<span class="icon-sun">☀️</span>
|
||
</button>
|
||
<button class="help-btn" onclick="showDependencies()" title="Check Tool Dependencies" id="depsBtn" style="margin-right: 5px;">🔧</button>
|
||
<button class="help-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
||
<div class="logo">
|
||
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Outer hexagon -->
|
||
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="2" fill="none"/>
|
||
<!-- Inner signal waves -->
|
||
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||
<!-- Center dot -->
|
||
<circle cx="50" cy="50" r="3" fill="#00d4ff"/>
|
||
<!-- Corner accents -->
|
||
<path d="M50 12 L55 17 L50 17 Z" fill="#00d4ff"/>
|
||
<path d="M50 88 L45 83 L50 83 Z" fill="#00d4ff"/>
|
||
</svg>
|
||
</div>
|
||
<h1>INTERCEPT</h1>
|
||
<p>Signal Intelligence // by smittix</p>
|
||
</header>
|
||
|
||
<div class="container">
|
||
<div class="main-content">
|
||
<div class="sidebar">
|
||
<!-- Mode Tabs - Grouped -->
|
||
<div class="mode-tabs-container">
|
||
<div class="tab-group">
|
||
<div class="tab-group-label">SDR / RF</div>
|
||
<div class="mode-tabs">
|
||
<button class="mode-tab active" onclick="switchMode('pager')"><span class="tab-icon">📟</span>Pager</button>
|
||
<button class="mode-tab" onclick="switchMode('sensor')"><span class="tab-icon">📡</span>433MHz</button>
|
||
<button class="mode-tab" onclick="switchMode('aircraft')"><span class="tab-icon">✈️</span>Aircraft</button>
|
||
<button class="mode-tab" onclick="switchMode('satellite')"><span class="tab-icon">🛰️</span>Satellite</button>
|
||
</div>
|
||
</div>
|
||
<div class="tab-group">
|
||
<div class="tab-group-label">Wireless</div>
|
||
<div class="mode-tabs">
|
||
<button class="mode-tab" onclick="switchMode('wifi')"><span class="tab-icon">📶</span>WiFi</button>
|
||
<button class="mode-tab" onclick="switchMode('bluetooth')"><span class="tab-icon">🔵</span>BT</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="rtlDeviceSection">
|
||
<h3>RTL-SDR Device</h3>
|
||
<div class="form-group">
|
||
<select id="deviceSelect">
|
||
{% if devices %}
|
||
{% for device in devices %}
|
||
<option value="{{ device.index }}">{{ device.index }}: {{ device.name }}</option>
|
||
{% endfor %}
|
||
{% else %}
|
||
<option value="0">No devices found</option>
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
|
||
Refresh Devices
|
||
</button>
|
||
<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 id="toolStatusAircraft" class="info-text tool-status-section" style="display: none; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
|
||
<span>dump1090:</span><span id="dump1090StatusSidebar" class="tool-status">Checking...</span>
|
||
<span>rtl_adsb:</span><span id="rtlAdsbStatusSidebar" class="tool-status">Checking...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PAGER MODE -->
|
||
<div id="pagerMode" class="mode-content active">
|
||
<div class="section">
|
||
<h3>Frequency</h3>
|
||
<div class="form-group">
|
||
<label>Frequency (MHz)</label>
|
||
<input type="text" id="frequency" value="153.350" placeholder="e.g., 153.350">
|
||
</div>
|
||
<div class="preset-buttons" id="presetButtons">
|
||
<!-- Populated by JavaScript -->
|
||
</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 5px;">
|
||
<input type="text" id="newPresetFreq" placeholder="New freq (MHz)" style="flex: 1; padding: 6px; background: #0f3460; border: 1px solid #1a1a2e; color: #fff; border-radius: 4px; font-size: 12px;">
|
||
<button class="preset-btn" onclick="addPreset()" style="background: #2ecc71;">Add</button>
|
||
</div>
|
||
<div style="margin-top: 5px;">
|
||
<button class="preset-btn" onclick="resetPresets()" style="font-size: 11px;">Reset to Defaults</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Protocols</h3>
|
||
<div class="checkbox-group">
|
||
<label><input type="checkbox" id="proto_pocsag512" checked> POCSAG-512</label>
|
||
<label><input type="checkbox" id="proto_pocsag1200" checked> POCSAG-1200</label>
|
||
<label><input type="checkbox" id="proto_pocsag2400" checked> POCSAG-2400</label>
|
||
<label><input type="checkbox" id="proto_flex" checked> FLEX</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Settings</h3>
|
||
<div class="form-group">
|
||
<label>Gain (dB, 0 = auto)</label>
|
||
<input type="text" id="gain" value="0" placeholder="0-49 or 0 for auto">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Squelch Level</label>
|
||
<input type="text" id="squelch" value="0" placeholder="0 = off">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>PPM Correction</label>
|
||
<input type="text" id="ppm" value="0" placeholder="Frequency correction">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Logging</h3>
|
||
<div class="checkbox-group" style="margin-bottom: 15px;">
|
||
<label>
|
||
<input type="checkbox" id="loggingEnabled" onchange="toggleLogging()">
|
||
Enable Logging
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Log file path</label>
|
||
<input type="text" id="logFilePath" value="pager_messages.log" placeholder="pager_messages.log">
|
||
</div>
|
||
</div>
|
||
|
||
<button class="run-btn" id="startBtn" onclick="startDecoding()">
|
||
Start Decoding
|
||
</button>
|
||
<button class="stop-btn" id="stopBtn" onclick="stopDecoding()" style="display: none;">
|
||
Stop Decoding
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 433MHz SENSOR MODE -->
|
||
<div id="sensorMode" class="mode-content">
|
||
<div class="section">
|
||
<h3>Frequency</h3>
|
||
<div class="form-group">
|
||
<label>Frequency (MHz)</label>
|
||
<input type="text" id="sensorFrequency" value="433.92" placeholder="e.g., 433.92">
|
||
</div>
|
||
<div class="preset-buttons">
|
||
<button class="preset-btn" onclick="setSensorFreq('433.92')">433.92</button>
|
||
<button class="preset-btn" onclick="setSensorFreq('315.00')">315.00</button>
|
||
<button class="preset-btn" onclick="setSensorFreq('868.00')">868.00</button>
|
||
<button class="preset-btn" onclick="setSensorFreq('915.00')">915.00</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Settings</h3>
|
||
<div class="form-group">
|
||
<label>Gain (dB, 0 = auto)</label>
|
||
<input type="text" id="sensorGain" value="0" placeholder="0-49 or 0 for auto">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>PPM Correction</label>
|
||
<input type="text" id="sensorPpm" value="0" placeholder="Frequency correction">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Protocols</h3>
|
||
<div class="info-text" style="margin-bottom: 10px;">
|
||
rtl_433 auto-detects 200+ device protocols including weather stations, TPMS, doorbells, and more.
|
||
</div>
|
||
<div class="checkbox-group">
|
||
<label>
|
||
<input type="checkbox" id="sensorLogging" onchange="toggleSensorLogging()">
|
||
Enable Logging
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
|
||
Start Listening
|
||
</button>
|
||
<button class="stop-btn" id="stopSensorBtn" onclick="stopSensorDecoding()" style="display: none;">
|
||
Stop Listening
|
||
</button>
|
||
</div>
|
||
|
||
<!-- WiFi MODE -->
|
||
<div id="wifiMode" class="mode-content">
|
||
<div class="section">
|
||
<h3>WiFi Interface</h3>
|
||
<div class="form-group">
|
||
<select id="wifiInterfaceSelect">
|
||
<option value="">Detecting interfaces...</option>
|
||
</select>
|
||
</div>
|
||
<button class="preset-btn" onclick="refreshWifiInterfaces()" style="width: 100%;">
|
||
Refresh Interfaces
|
||
</button>
|
||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="wifiToolStatus">
|
||
<span>airmon-ng:</span><span class="tool-status missing">Checking...</span>
|
||
<span>airodump-ng:</span><span class="tool-status missing">Checking...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Monitor Mode</h3>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button class="preset-btn" id="monitorStartBtn" onclick="enableMonitorMode()" style="flex: 1; background: var(--accent-green); color: #000;">
|
||
Enable Monitor
|
||
</button>
|
||
<button class="preset-btn" id="monitorStopBtn" onclick="disableMonitorMode()" style="flex: 1; display: none;">
|
||
Disable Monitor
|
||
</button>
|
||
</div>
|
||
<div class="checkbox-group" style="margin-top: 8px;">
|
||
<label style="font-size: 10px;">
|
||
<input type="checkbox" id="killProcesses">
|
||
Kill interfering processes (may drop other connections)
|
||
</label>
|
||
</div>
|
||
<div id="monitorStatus" class="info-text" style="margin-top: 8px;">
|
||
Monitor mode: <span style="color: var(--accent-red);">Inactive</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Scan Settings</h3>
|
||
<div class="form-group">
|
||
<label>Band</label>
|
||
<select id="wifiBand">
|
||
<option value="abg">All (2.4 + 5 GHz)</option>
|
||
<option value="bg">2.4 GHz only</option>
|
||
<option value="a">5 GHz only</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Channel (empty = hop)</label>
|
||
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Proximity Alerts</h3>
|
||
<div class="info-text" style="margin-bottom: 8px;">
|
||
Alert when specific MAC addresses appear
|
||
</div>
|
||
<div class="form-group">
|
||
<input type="text" id="watchMacInput" placeholder="AA:BB:CC:DD:EE:FF">
|
||
</div>
|
||
<button class="preset-btn" onclick="addWatchMac()" style="width: 100%; margin-bottom: 8px;">
|
||
Add to Watch List
|
||
</button>
|
||
<div id="watchList" style="max-height: 80px; overflow-y: auto; font-size: 10px; color: var(--text-dim);"></div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Attack Options</h3>
|
||
<div class="info-text" style="color: var(--accent-red); margin-bottom: 10px;">
|
||
⚠ Only use on authorized networks
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Target BSSID</label>
|
||
<input type="text" id="targetBssid" placeholder="AA:BB:CC:DD:EE:FF">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Target Client (optional)</label>
|
||
<input type="text" id="targetClient" placeholder="FF:FF:FF:FF:FF:FF (broadcast)">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Deauth Count</label>
|
||
<input type="text" id="deauthCount" value="5" placeholder="5">
|
||
</div>
|
||
<button class="preset-btn" onclick="sendDeauth()" style="width: 100%; border-color: var(--accent-red); color: var(--accent-red);">
|
||
Send Deauth
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Handshake Capture Status Panel -->
|
||
<div class="section" id="captureStatusPanel" style="display: none; border: 1px solid var(--accent-orange); border-radius: 4px; padding: 10px; background: rgba(255, 165, 0, 0.1);">
|
||
<h3 style="color: var(--accent-orange); margin: 0 0 8px 0;">🎯 Handshake Capture</h3>
|
||
<div style="font-size: 11px;">
|
||
<div style="margin-bottom: 4px;">
|
||
<span style="color: var(--text-dim);">Target:</span>
|
||
<span id="captureTargetBssid" style="font-family: monospace;">--</span>
|
||
</div>
|
||
<div style="margin-bottom: 4px;">
|
||
<span style="color: var(--text-dim);">Channel:</span>
|
||
<span id="captureTargetChannel">--</span>
|
||
</div>
|
||
<div style="margin-bottom: 4px;">
|
||
<span style="color: var(--text-dim);">File:</span>
|
||
<span id="captureFilePath" style="font-size: 9px; word-break: break-all;">--</span>
|
||
</div>
|
||
<div style="margin-bottom: 8px;">
|
||
<span style="color: var(--text-dim);">Status:</span>
|
||
<span id="captureStatus" style="font-weight: bold;">--</span>
|
||
</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button class="preset-btn" onclick="checkCaptureStatus()" style="flex: 1; font-size: 10px; padding: 4px;">
|
||
Check Status
|
||
</button>
|
||
<button class="preset-btn" onclick="stopHandshakeCapture()" style="flex: 1; font-size: 10px; padding: 4px; border-color: var(--accent-red); color: var(--accent-red);">
|
||
Stop Capture
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PMKID Capture Panel -->
|
||
<div class="section" id="pmkidPanel" style="display: none; border: 1px solid #9933ff; border-radius: 4px; padding: 10px; background: rgba(153, 51, 255, 0.1);">
|
||
<h3 style="color: #9933ff; margin: 0 0 8px 0;">🔐 PMKID Capture</h3>
|
||
<div style="font-size: 11px;">
|
||
<div style="margin-bottom: 4px;">
|
||
<span style="color: var(--text-dim);">Target:</span>
|
||
<span id="pmkidTargetBssid" style="font-family: monospace;">--</span>
|
||
</div>
|
||
<div style="margin-bottom: 4px;">
|
||
<span style="color: var(--text-dim);">Status:</span>
|
||
<span id="pmkidStatus" style="font-weight: bold;">--</span>
|
||
</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button class="preset-btn" onclick="stopPmkidCapture()" style="flex: 1; font-size: 10px; padding: 4px; border-color: var(--accent-red); color: var(--accent-red);">
|
||
Stop
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Beacon Flood Alert Panel -->
|
||
<div id="beaconFloodAlert" class="beacon-flood-alert" style="display: none;">
|
||
<h4 style="color: #ff4444; margin: 0 0 8px 0;">⚠️ BEACON FLOOD DETECTED</h4>
|
||
<div style="font-size: 11px;">
|
||
<div id="beaconFloodDetails">Multiple beacon frames detected from same channel</div>
|
||
<div style="margin-top: 8px;">
|
||
<span style="color: var(--text-dim);">Networks/sec:</span>
|
||
<span id="beaconFloodRate" style="font-weight: bold; color: #ff4444;">--</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()">
|
||
Start Scanning
|
||
</button>
|
||
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
|
||
Stop Scanning
|
||
</button>
|
||
</div>
|
||
|
||
<!-- BLUETOOTH MODE -->
|
||
<div id="bluetoothMode" class="mode-content">
|
||
<div class="section">
|
||
<h3>Bluetooth Interface</h3>
|
||
<div class="form-group">
|
||
<select id="btInterfaceSelect">
|
||
<option value="">Detecting interfaces...</option>
|
||
</select>
|
||
</div>
|
||
<button class="preset-btn" onclick="refreshBtInterfaces()" style="width: 100%;">
|
||
Refresh Interfaces
|
||
</button>
|
||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="btToolStatus">
|
||
<span>hcitool:</span><span class="tool-status missing">Checking...</span>
|
||
<span>bluetoothctl:</span><span class="tool-status missing">Checking...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Scan Mode</h3>
|
||
<div class="checkbox-group" style="margin-bottom: 10px;">
|
||
<label><input type="radio" name="btScanMode" value="bluetoothctl" checked> bluetoothctl (Recommended)</label>
|
||
<label><input type="radio" name="btScanMode" value="hcitool"> hcitool (Legacy)</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Scan Duration (sec)</label>
|
||
<input type="text" id="btScanDuration" value="30" placeholder="30">
|
||
</div>
|
||
<div class="checkbox-group">
|
||
<label>
|
||
<input type="checkbox" id="btScanBLE" checked>
|
||
Scan BLE Devices
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="btScanClassic" checked>
|
||
Scan Classic BT
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="btDetectBeacons" checked>
|
||
Detect Trackers (AirTag/Tile)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Device Actions</h3>
|
||
<div class="form-group">
|
||
<label>Target MAC</label>
|
||
<input type="text" id="btTargetMac" placeholder="AA:BB:CC:DD:EE:FF">
|
||
</div>
|
||
<button class="preset-btn" onclick="btEnumServices()" style="width: 100%;">
|
||
Enumerate Services
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tracker Following Alert -->
|
||
<div id="trackerFollowingAlert" class="tracker-following-alert" style="display: none;">
|
||
<!-- Populated by JavaScript -->
|
||
</div>
|
||
|
||
<button class="run-btn" id="startBtBtn" onclick="startBtScan()">
|
||
Start Scanning
|
||
</button>
|
||
<button class="stop-btn" id="stopBtBtn" onclick="stopBtScan()" style="display: none;">
|
||
Stop Scanning
|
||
</button>
|
||
<button class="preset-btn" onclick="resetBtAdapter()" style="margin-top: 5px; width: 100%;">
|
||
Reset Adapter
|
||
</button>
|
||
</div>
|
||
|
||
<!-- AIRCRAFT MODE (ADS-B) -->
|
||
<div id="aircraftMode" class="mode-content">
|
||
<div class="section">
|
||
<a href="/adsb/dashboard" target="_blank" class="run-btn" style="display: block; text-align: center; text-decoration: none; margin-bottom: 15px;">Full Screen Dashboard</a>
|
||
<h3>ADS-B Receiver</h3>
|
||
<div class="form-group">
|
||
<label>Frequency</label>
|
||
<input type="text" id="adsbFrequency" value="1090" readonly style="opacity: 0.7;">
|
||
<div class="info-text">Fixed at 1090 MHz</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Gain (dB)</label>
|
||
<input type="text" id="adsbGain" value="40" placeholder="40">
|
||
</div>
|
||
<div class="checkbox-group">
|
||
<label>
|
||
<input type="checkbox" id="adsbEnableMap" checked>
|
||
Show Radar Display
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Display Settings</h3>
|
||
<div class="form-group">
|
||
<label>Range (nm)</label>
|
||
<select id="adsbRange">
|
||
<option value="50">50 nm</option>
|
||
<option value="100" selected>100 nm</option>
|
||
<option value="200">200 nm</option>
|
||
<option value="500">500 nm</option>
|
||
</select>
|
||
</div>
|
||
<div class="checkbox-group">
|
||
<label>
|
||
<input type="checkbox" id="adsbShowLabels" checked>
|
||
Show Callsigns
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="adsbShowAltitude" checked>
|
||
Show Altitude
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="adsbShowTrails">
|
||
Show Flight Trails
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="adsbEnableClustering" onchange="toggleAircraftClustering()">
|
||
Cluster Markers
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="adsbShowRangeRings" onchange="drawRangeRings()">
|
||
Show Range Rings
|
||
</label>
|
||
</div>
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label>Observer Location</label>
|
||
<div style="display: flex; gap: 5px;">
|
||
<input type="text" id="adsbObsLat" value="51.5074" placeholder="Latitude" style="flex: 1;" onchange="updateObserverLocation()">
|
||
<input type="text" id="adsbObsLon" value="-0.1278" placeholder="Longitude" style="flex: 1;" onchange="updateObserverLocation()">
|
||
</div>
|
||
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;">
|
||
📍 Use GPS Location
|
||
</button>
|
||
</div>
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label>Aircraft Filter</label>
|
||
<select id="adsbAircraftFilter" onchange="applyAircraftFilter()">
|
||
<option value="all">All Aircraft</option>
|
||
<option value="military">Military Only</option>
|
||
<option value="civil">Civil Only</option>
|
||
<option value="emergency">Emergency Only</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||
<input type="checkbox" id="adsbAlertToggle" checked onchange="adsbAlertsEnabled = this.checked">
|
||
🔔 Audio Alerts (Military/Emergency)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Reception Statistics</h3>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 11px;">
|
||
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
|
||
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Max Range</div>
|
||
<div id="adsbMaxRange" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0.0 nm</div>
|
||
</div>
|
||
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
|
||
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Total Seen</div>
|
||
<div id="adsbTotalSeen" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0</div>
|
||
</div>
|
||
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
|
||
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Msg Rate</div>
|
||
<div id="adsbMsgRate" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0.0/s</div>
|
||
</div>
|
||
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
|
||
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Busiest Hour</div>
|
||
<div id="adsbBusiestHour" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">--</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="adsbToolStatus">
|
||
<span>dump1090:</span><span class="tool-status" id="dump1090Status">Checking...</span>
|
||
<span>rtl_adsb:</span><span class="tool-status" id="rtlAdsbStatus">Checking...</span>
|
||
</div>
|
||
|
||
<button class="run-btn" id="startAdsbBtn" onclick="startAdsbScan()">
|
||
Start Tracking
|
||
</button>
|
||
<button class="stop-btn" id="stopAdsbBtn" onclick="stopAdsbScan()" style="display: none;">
|
||
Stop Tracking
|
||
</button>
|
||
</div>
|
||
|
||
<!-- SATELLITE MODE -->
|
||
<div id="satelliteMode" class="mode-content">
|
||
<a href="/satellite/dashboard" target="_blank" class="run-btn" style="display: block; text-align: center; text-decoration: none; margin-bottom: 15px;">Full Screen Dashboard</a>
|
||
<div class="satellite-tabs">
|
||
<button class="satellite-tab active" onclick="switchSatelliteTab('predictor')">🛰️ Pass Predictor</button>
|
||
<button class="satellite-tab" onclick="switchSatelliteTab('iridium')">📡 Iridium</button>
|
||
</div>
|
||
|
||
<!-- Pass Predictor Sub-tab -->
|
||
<div id="predictorTab" class="satellite-content active">
|
||
<div class="section">
|
||
<h3>Observer Location</h3>
|
||
<div class="form-group">
|
||
<label>Latitude</label>
|
||
<input type="text" id="obsLat" value="51.5074" placeholder="51.5074">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Longitude</label>
|
||
<input type="text" id="obsLon" value="-0.1278" placeholder="-0.1278">
|
||
</div>
|
||
<button class="preset-btn" onclick="getLocation()" style="width: 100%;">
|
||
📍 Use My Location
|
||
</button>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Satellites to Track</h3>
|
||
<div id="satelliteList" class="satellite-list">
|
||
<!-- Dynamically populated -->
|
||
</div>
|
||
<div style="margin-top: 10px; display: flex; gap: 5px;">
|
||
<button class="preset-btn" onclick="showAddSatelliteModal()" style="flex: 1;">
|
||
➕ Add Satellite
|
||
</button>
|
||
<button class="preset-btn" onclick="fetchCelestrak()" style="flex: 1;">
|
||
🌐 Celestrak
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Prediction Settings</h3>
|
||
<div class="form-group">
|
||
<label>Time Range</label>
|
||
<select id="predictionHours">
|
||
<option value="12">12 hours</option>
|
||
<option value="24" selected>24 hours</option>
|
||
<option value="48">48 hours</option>
|
||
<option value="72">72 hours</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Min Elevation</label>
|
||
<select id="minElevation">
|
||
<option value="0">0° (All passes)</option>
|
||
<option value="10" selected>10° (Good)</option>
|
||
<option value="30">30° (Best)</option>
|
||
<option value="45">45° (Overhead)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="run-btn" onclick="calculatePasses()">
|
||
Calculate Passes
|
||
</button>
|
||
<button class="preset-btn" onclick="updateTLE()" style="width: 100%; margin-top: 5px;">
|
||
🔄 Update TLE Data
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Iridium Sub-tab -->
|
||
<div id="iridiumTab" class="satellite-content">
|
||
<div class="iridium-warning">
|
||
<strong>⚠️ Hardware Required</strong>
|
||
Iridium burst detection requires:<br>
|
||
• RTL-SDR dongle<br>
|
||
• L-band patch antenna (1616-1626 MHz)<br>
|
||
• Low Noise Amplifier (LNA)<br>
|
||
• Clear view of sky
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Iridium Settings</h3>
|
||
<div class="form-group">
|
||
<label>Center Frequency (MHz)</label>
|
||
<input type="text" id="iridiumFreq" value="1626.0" placeholder="1626.0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Gain (dB)</label>
|
||
<input type="text" id="iridiumGain" value="40" placeholder="40">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Sample Rate</label>
|
||
<select id="iridiumSampleRate">
|
||
<option value="2.4e6">2.4 MSPS</option>
|
||
<option value="2.048e6" selected>2.048 MSPS</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-text" style="margin-bottom: 10px;" id="iridiumToolStatus">
|
||
<span>iridium-extractor:</span> <span class="tool-status" id="iridiumExtractorStatus">Checking...</span>
|
||
</div>
|
||
|
||
<button class="run-btn" id="startIridiumBtn" onclick="startIridiumCapture()">
|
||
Start Capture
|
||
</button>
|
||
<button class="stop-btn" id="stopIridiumBtn" onclick="stopIridiumCapture()" style="display: none;">
|
||
Stop Capture
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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 id="signalMeter" class="signal-meter" title="Signal Activity">
|
||
<div class="signal-bar"></div>
|
||
<div class="signal-bar"></div>
|
||
<div class="signal-bar"></div>
|
||
<div class="signal-bar"></div>
|
||
<div class="signal-bar"></div>
|
||
</div>
|
||
<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="aircraftStats" style="display: none;">
|
||
<div title="Aircraft Tracked">✈️ <span id="aircraftCount">0</span></div>
|
||
<div title="Messages Received">📨 <span id="adsbMsgCount">0</span></div>
|
||
<div title="Unique ICAO Codes">🔢 <span id="icaoCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="satelliteStats" style="display: none;">
|
||
<div title="Upcoming Passes">🛰️ <span id="passCount">0</span></div>
|
||
<div title="Iridium Bursts">📡 <span id="burstCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Visualizations (shown only in WiFi mode) -->
|
||
<div class="wifi-visuals" id="wifiVisuals" style="display: none;">
|
||
<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>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>
|
||
<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>
|
||
<div class="wifi-visual-panel">
|
||
<h5>Target Signal</h5>
|
||
<div class="signal-strength-display">
|
||
<div class="target-ssid" id="targetSsid">No target selected</div>
|
||
<div class="signal-value" id="signalValue">-- dBm</div>
|
||
<div class="signal-bars-large">
|
||
<div class="signal-bar-large"></div>
|
||
<div class="signal-bar-large"></div>
|
||
<div class="signal-bar-large"></div>
|
||
<div class="signal-bar-large"></div>
|
||
<div class="signal-bar-large"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal Strength History Graph -->
|
||
<div class="wifi-visual-panel signal-graph-panel" id="signalGraphPanel" style="grid-column: span 2;">
|
||
<div class="signal-graph-header">
|
||
<h4>📈 Signal History</h4>
|
||
<span class="signal-graph-device" id="signalGraphDevice">Click a device to track</span>
|
||
</div>
|
||
<canvas id="signalGraph"></canvas>
|
||
</div>
|
||
<!-- Network Relationship Graph -->
|
||
<div class="wifi-visual-panel network-graph-container" style="grid-column: span 2;">
|
||
<h4>🕸️ Network Topology</h4>
|
||
<canvas id="networkGraph"></canvas>
|
||
<div class="network-graph-legend">
|
||
<div class="legend-item"><div class="legend-dot ap"></div>Access Point</div>
|
||
<div class="legend-item"><div class="legend-dot client"></div>Client</div>
|
||
<div class="legend-item"><div class="legend-dot drone"></div>Drone</div>
|
||
</div>
|
||
</div>
|
||
<!-- 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>
|
||
|
||
<!-- Bluetooth Visualizations -->
|
||
<div class="wifi-visuals" id="btVisuals" style="display: none;">
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<h5>Bluetooth Proximity Radar</h5>
|
||
<div class="radar-container">
|
||
<canvas id="btRadarCanvas" width="150" height="150"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aircraft Visualizations - Leaflet Map -->
|
||
<div class="wifi-visuals" id="aircraftVisuals" style="display: none;">
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<h5 style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan);">ADS-B AIRCRAFT TRACKING</h5>
|
||
<div class="aircraft-map-container">
|
||
<div class="map-header">
|
||
<span id="radarTime">--:--:--</span>
|
||
<span id="radarStatus">TRACKING</span>
|
||
</div>
|
||
<div id="aircraftMap"></div>
|
||
<div class="map-footer">
|
||
<span>AIRCRAFT: <span id="aircraftCount">0</span></span>
|
||
<span>CENTER: <span id="mapCenter">--</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Visualizations -->
|
||
<div id="satelliteVisuals" style="display: none;">
|
||
<div class="pass-predictor">
|
||
<!-- Cell 1: Polar Plot (Top Left) -->
|
||
<div class="polar-plot-container">
|
||
<div class="polar-plot-header">
|
||
<span class="polar-plot-title">Sky View</span>
|
||
<button class="popout-btn" onclick="popoutSatellite()">⛶ Pop Out</button>
|
||
</div>
|
||
<div class="polar-plot">
|
||
<canvas id="polarPlotCanvas"></canvas>
|
||
</div>
|
||
<div style="text-align: center; margin-top: 10px; font-size: 10px; color: var(--text-secondary);">
|
||
<span style="color: var(--accent-cyan);">N</span> = North |
|
||
Center = Overhead (90°) |
|
||
Edge = Horizon (0°)
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cell 2: Ground Track Map (Top Right) -->
|
||
<div class="ground-track-cell">
|
||
<div class="ground-track-header">
|
||
<span class="ground-track-title">🌍 Ground Track</span>
|
||
<label style="font-size: 11px; display: flex; align-items: center; gap: 5px;">
|
||
<input type="checkbox" id="showGroundTrack" checked onchange="toggleGroundTrack()">
|
||
Show Track
|
||
</label>
|
||
</div>
|
||
<div id="groundTrackMap"></div>
|
||
<div style="text-align: center; margin-top: 8px; font-size: 10px; color: var(--text-secondary);">
|
||
<span style="color: #666;">---</span> Past |
|
||
<span style="color: #ffff00;">●</span> Current |
|
||
<span style="color: #00ff00;">―</span> Future |
|
||
<span style="color: #ff6600;">◉</span> Observer
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cell 3: Countdown (Bottom Left) -->
|
||
<div class="countdown-cell">
|
||
<div class="pass-list-header">
|
||
<span>Next Pass Countdown</span>
|
||
</div>
|
||
<div id="satelliteCountdown" class="satellite-countdown">
|
||
<div class="countdown-satellite-name" id="countdownSatName">--</div>
|
||
<div class="countdown-grid">
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Next Pass In</div>
|
||
<div class="countdown-value" id="countdownToPass">--:--:--</div>
|
||
<div class="countdown-sublabel" id="countdownPassTime">--</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Visibility</div>
|
||
<div class="countdown-value" id="countdownVisibility">--:--</div>
|
||
<div class="countdown-sublabel" id="countdownVisLabel">Duration</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Max Elevation</div>
|
||
<div class="countdown-value" id="countdownMaxEl">--°</div>
|
||
<div class="countdown-sublabel" id="countdownDirection">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="countdown-status" id="countdownStatus">Calculate passes to see countdown</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cell 4: Pass List (Bottom Right) -->
|
||
<div class="pass-list-cell">
|
||
<div class="pass-list-header">
|
||
<span>Upcoming Passes</span>
|
||
<span id="passListCount">0 passes</span>
|
||
</div>
|
||
<div id="passList">
|
||
<div style="color: #666; text-align: center; padding: 30px; font-size: 11px;">
|
||
Click "Calculate Passes" to predict satellite passes for your location.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Iridium Burst Log -->
|
||
<div id="iridiumBurstLog" style="display: none; margin-top: 15px;">
|
||
<div class="pass-list-container">
|
||
<div class="pass-list-header">
|
||
<span>Iridium Burst Log</span>
|
||
<button class="preset-btn" onclick="clearIridiumLog()" style="padding: 2px 8px; font-size: 10px;">Clear</button>
|
||
</div>
|
||
<div id="burstList">
|
||
<div style="color: #666; text-align: center; padding: 30px; font-size: 11px;">
|
||
Iridium bursts will appear here when detected.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Popout Container -->
|
||
<div id="satellitePopout" class="popout-container">
|
||
<div class="popout-header">
|
||
<span class="popout-title">🛰️ Satellite Pass Predictor</span>
|
||
<button class="popout-close" onclick="closeSatellitePopout()">✕ Close</button>
|
||
</div>
|
||
<div class="popout-body">
|
||
<div class="pass-predictor" style="height: 100%;">
|
||
<div class="polar-plot-container" style="height: 100%;">
|
||
<div class="polar-plot-header">
|
||
<span class="polar-plot-title">Sky View - Full Screen</span>
|
||
</div>
|
||
<div style="height: calc(100% - 80px);">
|
||
<canvas id="polarPlotCanvasPopout" style="width: 100%; height: 100%;"></canvas>
|
||
</div>
|
||
<div style="text-align: center; margin-top: 10px; font-size: 12px; color: var(--text-secondary);">
|
||
<span style="color: var(--accent-cyan);">N</span> = North |
|
||
Center = Overhead (90°) |
|
||
Edge = Horizon (0°)
|
||
</div>
|
||
</div>
|
||
<div class="pass-list-container" style="height: 100%; max-height: none;">
|
||
<div class="pass-list-header">
|
||
<span>Upcoming Passes</span>
|
||
</div>
|
||
<!-- Countdown Block for Popout -->
|
||
<div id="satelliteCountdownPopout" class="satellite-countdown" style="display: none;">
|
||
<div class="countdown-satellite-name" id="countdownSatNamePopout">--</div>
|
||
<div class="countdown-grid">
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Next Pass In</div>
|
||
<div class="countdown-value" id="countdownToPassPopout">--:--:--</div>
|
||
<div class="countdown-sublabel" id="countdownPassTimePopout">--</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Visibility</div>
|
||
<div class="countdown-value" id="countdownVisibilityPopout">--:--</div>
|
||
<div class="countdown-sublabel" id="countdownVisLabelPopout">Duration</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Max Elevation</div>
|
||
<div class="countdown-value" id="countdownMaxElPopout">--°</div>
|
||
<div class="countdown-sublabel" id="countdownDirectionPopout">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="countdown-status" id="countdownStatusPopout">Waiting for pass data...</div>
|
||
</div>
|
||
<div id="passListPopout" style="height: calc(100% - 40px); overflow-y: auto;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Add Modal -->
|
||
<div id="satModal" class="sat-modal">
|
||
<div class="sat-modal-content">
|
||
<div class="sat-modal-header">
|
||
<h3>🛰️ Add Satellites</h3>
|
||
<button class="sat-modal-close" onclick="closeSatModal()">×</button>
|
||
</div>
|
||
<div class="sat-modal-tabs">
|
||
<button class="sat-modal-tab active" onclick="switchSatModalTab('tle')">Paste TLE</button>
|
||
<button class="sat-modal-tab" onclick="switchSatModalTab('celestrak')">Celestrak</button>
|
||
</div>
|
||
<div id="tleSection" class="sat-modal-section active">
|
||
<p style="font-size: 11px; color: var(--text-secondary); margin-bottom: 10px;">
|
||
Paste TLE data (3 lines per satellite: name, line 1, line 2)
|
||
</p>
|
||
<textarea id="tleInput" class="tle-textarea" placeholder="SATELLITE NAME 1 NNNNN... 2 NNNNN..."></textarea>
|
||
<button class="run-btn" onclick="addFromTLE()" style="margin-top: 10px;">
|
||
Add Satellites from TLE
|
||
</button>
|
||
</div>
|
||
<div id="celestrakSection" class="sat-modal-section">
|
||
<p style="font-size: 11px; color: var(--text-secondary); margin-bottom: 10px;">
|
||
Select a category to fetch satellites from Celestrak
|
||
</p>
|
||
<div class="celestrak-categories">
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('stations')">🚀 Space Stations</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('visual')">👁️ Brightest</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('weather')">🌤️ Weather</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('noaa')">📡 NOAA</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('amateur')">📻 Amateur Radio</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('starlink')">⭐ Starlink</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('gps-ops')">🛰️ GPS</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('iridium')">📱 Iridium</button>
|
||
</div>
|
||
<div id="celestrakStatus" style="margin-top: 10px; font-size: 11px; color: var(--text-secondary);"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||
<div class="recon-panel" 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="waterfall-container">
|
||
<canvas id="waterfallCanvas" width="800" height="60"></canvas>
|
||
</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>
|
||
|
||
<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">
|
||
<button id="reconBtn" class="recon-toggle" onclick="toggleRecon()">RECON</button>
|
||
<button id="muteBtn" class="control-btn" onclick="toggleMute()">🔊 MUTE</button>
|
||
<button id="autoScrollBtn" class="control-btn" onclick="toggleAutoScroll()">⬇ AUTO-SCROLL ON</button>
|
||
<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>
|
||
<button class="clear-btn" onclick="clearMessages()">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Disclaimer handling
|
||
function checkDisclaimer() {
|
||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||
if (accepted === 'true') {
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
}
|
||
}
|
||
|
||
function acceptDisclaimer() {
|
||
localStorage.setItem('disclaimerAccepted', 'true');
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
}
|
||
|
||
function declineDisclaimer() {
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||
}
|
||
|
||
// Check disclaimer on load
|
||
checkDisclaimer();
|
||
|
||
let eventSource = null;
|
||
let isRunning = false;
|
||
let isSensorRunning = false;
|
||
let isAdsbRunning = false;
|
||
let isIridiumRunning = false;
|
||
let isWifiRunning = false;
|
||
let isBtRunning = false;
|
||
let currentMode = 'pager';
|
||
let msgCount = 0;
|
||
let pocsagCount = 0;
|
||
let flexCount = 0;
|
||
let sensorCount = 0;
|
||
let deviceList = {{ devices | tojson | safe }};
|
||
|
||
// Aircraft (ADS-B) state
|
||
let adsbAircraft = {};
|
||
let adsbMsgCount = 0;
|
||
let adsbEventSource = null;
|
||
let aircraftTrails = {}; // ICAO -> array of positions
|
||
let activeSquawkAlerts = {}; // Active emergency squawk alerts
|
||
let alertedAircraft = {}; // Track aircraft that have already triggered alerts
|
||
let adsbAlertsEnabled = true; // Toggle for audio alerts
|
||
|
||
// ADS-B Statistics tracking
|
||
let adsbStats = {
|
||
totalAircraftSeen: new Set(), // Unique ICAO codes seen
|
||
maxRange: 0, // Max distance in nm
|
||
maxRangeAircraft: null, // Aircraft that achieved max range
|
||
hourlyCount: {}, // Hour -> count of aircraft
|
||
messagesPerSecond: 0, // Current msg/sec rate
|
||
messageTimestamps: [], // Recent message timestamps for rate calc
|
||
sessionStart: null // When tracking started
|
||
};
|
||
|
||
// Observer location for distance calculations
|
||
let observerLocation = { lat: 51.5074, lon: -0.1278 }; // Default London
|
||
let rangeRingsLayer = null;
|
||
let observerMarkerAdsb = null;
|
||
|
||
// Audio alert system using Web Audio API (uses shared audioContext declared later)
|
||
function getAdsbAudioContext() {
|
||
if (!window.adsbAudioCtx) {
|
||
window.adsbAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
return window.adsbAudioCtx;
|
||
}
|
||
|
||
function playAlertSound(type) {
|
||
if (!adsbAlertsEnabled) return;
|
||
try {
|
||
const ctx = getAdsbAudioContext();
|
||
const oscillator = ctx.createOscillator();
|
||
const gainNode = ctx.createGain();
|
||
|
||
oscillator.connect(gainNode);
|
||
gainNode.connect(ctx.destination);
|
||
|
||
if (type === 'emergency') {
|
||
// Urgent two-tone alert for emergencies
|
||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.5);
|
||
} else if (type === 'military') {
|
||
// Single tone for military
|
||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.3);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Audio alert failed:', e);
|
||
}
|
||
}
|
||
|
||
function checkAndAlertAircraft(icao, aircraft) {
|
||
// Skip if already alerted for this aircraft
|
||
if (alertedAircraft[icao]) return;
|
||
|
||
const militaryInfo = isMilitaryAircraft(icao, aircraft.callsign);
|
||
const squawkInfo = checkSquawkCode(aircraft);
|
||
|
||
if (squawkInfo) {
|
||
alertedAircraft[icao] = 'emergency';
|
||
playAlertSound('emergency');
|
||
showAlertBanner(`⚠️ EMERGENCY: ${squawkInfo.name} - ${aircraft.callsign || icao}`, squawkInfo.color);
|
||
} else if (militaryInfo.military) {
|
||
alertedAircraft[icao] = 'military';
|
||
playAlertSound('military');
|
||
showAlertBanner(`🎖️ MILITARY: ${aircraft.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
|
||
}
|
||
}
|
||
|
||
function showAlertBanner(message, color) {
|
||
const banner = document.createElement('div');
|
||
banner.style.cssText = `
|
||
position: fixed;
|
||
top: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: ${color};
|
||
color: white;
|
||
padding: 12px 24px;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
z-index: 10000;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||
animation: slideDown 0.3s ease-out;
|
||
`;
|
||
banner.textContent = message;
|
||
document.body.appendChild(banner);
|
||
|
||
// Auto-remove after 5 seconds
|
||
setTimeout(() => {
|
||
banner.style.animation = 'fadeOut 0.3s ease-out forwards';
|
||
setTimeout(() => banner.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
|
||
// Emergency squawk codes
|
||
const SQUAWK_CODES = {
|
||
'7500': { type: 'hijack', name: 'HIJACK', color: '#ff0000', description: 'Aircraft being hijacked' },
|
||
'7600': { type: 'radio', name: 'RADIO FAILURE', color: '#ff6600', description: 'Radio communications failure' },
|
||
'7700': { type: 'mayday', name: 'EMERGENCY', color: '#ff0000', description: 'General emergency' }
|
||
};
|
||
|
||
// Military ICAO hex ranges (specific military-only sub-ranges)
|
||
const MILITARY_RANGES = [
|
||
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' }, // US Military
|
||
{ start: 0xAE0000, end: 0xAEFFFF, country: 'US' }, // US Military
|
||
{ start: 0x3F4000, end: 0x3F7FFF, country: 'FR' }, // France Military (Armee de l'Air)
|
||
{ start: 0x43C000, end: 0x43CFFF, country: 'UK' }, // UK Military (RAF)
|
||
{ start: 0x3D0000, end: 0x3DFFFF, country: 'DE' }, // Germany Military (Luftwaffe)
|
||
{ start: 0x501C00, end: 0x501FFF, country: 'NATO' }, // NATO
|
||
];
|
||
|
||
// Military callsign prefixes
|
||
const MILITARY_PREFIXES = [
|
||
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
|
||
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
|
||
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
|
||
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
|
||
];
|
||
|
||
function isMilitaryAircraft(icao, callsign) {
|
||
// Check ICAO hex range
|
||
const icaoInt = parseInt(icao, 16);
|
||
for (const range of MILITARY_RANGES) {
|
||
if (icaoInt >= range.start && icaoInt <= range.end) {
|
||
return { military: true, country: range.country };
|
||
}
|
||
}
|
||
|
||
// Check callsign prefix
|
||
if (callsign) {
|
||
const upper = callsign.toUpperCase();
|
||
for (const prefix of MILITARY_PREFIXES) {
|
||
if (upper.startsWith(prefix)) {
|
||
return { military: true, type: 'callsign' };
|
||
}
|
||
}
|
||
}
|
||
|
||
return { military: false };
|
||
}
|
||
|
||
function checkSquawkCode(aircraft) {
|
||
if (!aircraft.squawk) return null;
|
||
|
||
const squawkInfo = SQUAWK_CODES[aircraft.squawk];
|
||
if (squawkInfo) {
|
||
// Show alert if not already shown
|
||
if (!activeSquawkAlerts[aircraft.icao]) {
|
||
activeSquawkAlerts[aircraft.icao] = true;
|
||
showSquawkAlert(aircraft, squawkInfo);
|
||
}
|
||
return squawkInfo;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function showSquawkAlert(aircraft, squawkInfo) {
|
||
// Create banner alert
|
||
const banner = document.createElement('div');
|
||
banner.className = 'squawk-alert-banner';
|
||
banner.id = 'squawkBanner_' + aircraft.icao;
|
||
banner.innerHTML = `
|
||
⚠️ ${squawkInfo.name} - ${aircraft.callsign || aircraft.icao} (${aircraft.squawk})
|
||
<br><small>${squawkInfo.description}</small>
|
||
<button onclick="this.parentElement.remove()" style="margin-left: 20px; background: transparent; border: 1px solid white; color: white; padding: 2px 10px; cursor: pointer;">✕</button>
|
||
`;
|
||
document.body.appendChild(banner);
|
||
|
||
// Auto-remove after 30 seconds
|
||
setTimeout(() => {
|
||
const el = document.getElementById('squawkBanner_' + aircraft.icao);
|
||
if (el) el.remove();
|
||
}, 30000);
|
||
|
||
// Audio alert
|
||
if (!muted) {
|
||
for (let i = 0; i < 5; i++) {
|
||
setTimeout(() => playAlertSound(), i * 200);
|
||
}
|
||
}
|
||
|
||
showNotification(`⚠️ ${squawkInfo.name}`, `${aircraft.callsign || aircraft.icao} - Squawk ${aircraft.squawk}`);
|
||
}
|
||
|
||
function updateAircraftTrail(icao, lat, lon) {
|
||
if (!aircraftTrails[icao]) {
|
||
aircraftTrails[icao] = [];
|
||
}
|
||
|
||
const trail = aircraftTrails[icao];
|
||
const lastPos = trail[trail.length - 1];
|
||
|
||
// Only add if position changed significantly
|
||
if (!lastPos || Math.abs(lastPos.lat - lat) > 0.001 || Math.abs(lastPos.lon - lon) > 0.001) {
|
||
trail.push({ lat, lon, time: Date.now() });
|
||
|
||
// Keep only last 100 positions (about 10 minutes at 1 update/6 seconds)
|
||
if (trail.length > 100) {
|
||
trail.shift();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Satellite state
|
||
let satellitePasses = [];
|
||
let selectedPass = null;
|
||
let selectedPassIndex = 0;
|
||
let iridiumBursts = [];
|
||
let iridiumEventSource = null;
|
||
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');
|
||
});
|
||
});
|
||
});
|
||
|
||
// Toggle section collapse
|
||
function toggleSection(el) {
|
||
el.closest('.section').classList.toggle('collapsed');
|
||
}
|
||
|
||
// 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 (isAdsbRunning) stopAdsbScan();
|
||
if (isIridiumRunning) stopIridiumCapture();
|
||
|
||
currentMode = mode;
|
||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
||
const tabText = tab.textContent.toLowerCase();
|
||
const isActive = (mode === 'pager' && tabText.includes('pager')) ||
|
||
(mode === 'sensor' && tabText.includes('433')) ||
|
||
(mode === 'aircraft' && tabText.includes('aircraft')) ||
|
||
(mode === 'satellite' && tabText.includes('satellite')) ||
|
||
(mode === 'wifi' && tabText.includes('wifi')) ||
|
||
(mode === 'bluetooth' && tabText === 'bt');
|
||
tab.classList.toggle('active', isActive);
|
||
});
|
||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||
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('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? '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';
|
||
document.getElementById('wifiVisuals').style.display = mode === 'wifi' ? 'grid' : 'none';
|
||
document.getElementById('btVisuals').style.display = mode === 'bluetooth' ? 'grid' : 'none';
|
||
document.getElementById('aircraftVisuals').style.display = mode === 'aircraft' ? 'grid' : 'none';
|
||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||
|
||
// Update output panel title based on mode
|
||
const titles = {
|
||
'pager': 'Pager Decoder',
|
||
'sensor': '433MHz Sensor Monitor',
|
||
'aircraft': 'ADS-B Aircraft Tracker',
|
||
'satellite': 'Satellite Monitor',
|
||
'wifi': 'WiFi Scanner',
|
||
'bluetooth': 'Bluetooth Scanner'
|
||
};
|
||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||
|
||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft)
|
||
const reconBtn = document.getElementById('reconBtn');
|
||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||
if (mode === 'satellite' || mode === 'aircraft') {
|
||
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';
|
||
}
|
||
|
||
// Show RTL-SDR device section for modes that use it
|
||
document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? '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';
|
||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||
|
||
// Hide waterfall and output console for satellite mode (uses its own visualizations)
|
||
document.querySelector('.waterfall-container').style.display = (mode === 'satellite') ? 'none' : 'block';
|
||
document.getElementById('output').style.display = (mode === 'satellite') ? '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 === 'aircraft') {
|
||
checkAdsbTools();
|
||
initAircraftRadar();
|
||
} else if (mode === 'satellite') {
|
||
initPolarPlot();
|
||
initSatelliteList();
|
||
checkIridiumTools();
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
ppm: ppm,
|
||
device: device
|
||
};
|
||
|
||
fetch('/start_sensor', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
setSensorRunning(true);
|
||
startSensorStream();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Stop sensor decoding
|
||
function stopSensorDecoding() {
|
||
fetch('/stop_sensor', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
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'})
|
||
});
|
||
}
|
||
|
||
// Audio alert settings
|
||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||
let audioContext = null;
|
||
|
||
function initAudio() {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
}
|
||
|
||
function playAlert() {
|
||
if (audioMuted || !audioContext) return;
|
||
const oscillator = audioContext.createOscillator();
|
||
const gainNode = audioContext.createGain();
|
||
oscillator.connect(gainNode);
|
||
gainNode.connect(audioContext.destination);
|
||
oscillator.frequency.value = 880;
|
||
oscillator.type = 'sine';
|
||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||
oscillator.start(audioContext.currentTime);
|
||
oscillator.stop(audioContext.currentTime + 0.2);
|
||
}
|
||
|
||
function toggleMute() {
|
||
audioMuted = !audioMuted;
|
||
localStorage.setItem('audioMuted', audioMuted);
|
||
updateMuteButton();
|
||
}
|
||
|
||
function updateMuteButton() {
|
||
const btn = document.getElementById('muteBtn');
|
||
if (btn) {
|
||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||
btn.classList.toggle('muted', audioMuted);
|
||
}
|
||
}
|
||
|
||
// 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
|
||
const canvas = document.getElementById('waterfallCanvas');
|
||
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() {
|
||
const canvas = document.getElementById('waterfallCanvas');
|
||
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();
|
||
|
||
// Initialize audio context on first user interaction (required by browsers)
|
||
document.addEventListener('click', function initAudioOnClick() {
|
||
initAudio();
|
||
document.removeEventListener('click', initAudioOnClick);
|
||
}, { once: true });
|
||
|
||
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);
|
||
});
|
||
}
|
||
}
|
||
|
||
function refreshDevices() {
|
||
fetch('/devices')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
deviceList = devices;
|
||
const select = document.getElementById('deviceSelect');
|
||
if (devices.length === 0) {
|
||
select.innerHTML = '<option value="0">No devices found</option>';
|
||
} else {
|
||
select.innerHTML = devices.map(d =>
|
||
`<option value="${d.index}">${d.index}: ${d.name}</option>`
|
||
).join('');
|
||
}
|
||
});
|
||
}
|
||
|
||
function getSelectedDevice() {
|
||
return document.getElementById('deviceSelect').value;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
squelch: squelch,
|
||
ppm: ppm,
|
||
device: device,
|
||
protocols: protocols
|
||
};
|
||
|
||
fetch('/start', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
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 => {
|
||
setRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function killAll() {
|
||
fetch('/killall', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setRunning(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();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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
|
||
allMessages.push(msg);
|
||
|
||
// Play audio alert
|
||
playAlert();
|
||
|
||
// Update signal meter
|
||
pulseSignal();
|
||
|
||
// Add to waterfall
|
||
addWaterfallPoint(Date.now(), 0.8);
|
||
|
||
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;
|
||
}
|
||
|
||
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">${msg.protocol}</span>
|
||
<span class="msg-time" data-timestamp="${msg.timestamp}" title="${msg.timestamp}">${relativeTime}</span>
|
||
</div>
|
||
<div class="address">Address: ${msg.address}${msg.function ? ' | Func: ' + 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;
|
||
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, 8)}</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') ? '▶' : '▼';
|
||
}
|
||
|
||
// ============== 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 activeCapture = null; // {bssid, channel, file, startTime, pollInterval}
|
||
let watchMacs = JSON.parse(localStorage.getItem('watchMacs') || '[]');
|
||
let alertedMacs = new Set(); // Prevent duplicate alerts per session
|
||
|
||
// 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)
|
||
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 || '?',
|
||
firstSeen: new Date().toLocaleTimeString()
|
||
});
|
||
}
|
||
|
||
const isNewBssid = !ssidToBssids[ssid].has(bssid);
|
||
ssidToBssids[ssid].add(bssid);
|
||
|
||
// If we have more than one BSSID for this SSID, it could be rogue (or just multiple APs)
|
||
if (ssidToBssids[ssid].size > 1 && isNewBssid) {
|
||
rogueApCount++;
|
||
document.getElementById('rogueApCount').textContent = rogueApCount;
|
||
playAlert();
|
||
|
||
// Get the BSSIDs to show in alert
|
||
const bssidList = rogueApDetails[ssid].map(e => e.bssid).join(', ');
|
||
showInfo(`⚠ Rogue AP: "${ssid}" has ${ssidToBssids[ssid].size} BSSIDs: ${bssidList}`);
|
||
showNotification('⚠️ Rogue AP Detected!', `"${ssid}" on multiple BSSIDs`);
|
||
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 ==============
|
||
|
||
// Signal History Graph
|
||
let signalHistory = {}; // {mac: [{time, signal}]}
|
||
let trackedDevice = null;
|
||
const maxSignalPoints = 60;
|
||
|
||
function trackDeviceSignal(mac, signal) {
|
||
if (!signalHistory[mac]) {
|
||
signalHistory[mac] = [];
|
||
}
|
||
signalHistory[mac].push({
|
||
time: Date.now(),
|
||
signal: parseInt(signal) || -100
|
||
});
|
||
// Keep only last N points
|
||
if (signalHistory[mac].length > maxSignalPoints) {
|
||
signalHistory[mac].shift();
|
||
}
|
||
// Update graph if this is the tracked device
|
||
if (trackedDevice === mac) {
|
||
drawSignalGraph();
|
||
}
|
||
}
|
||
|
||
function setTrackedDevice(mac, name) {
|
||
trackedDevice = mac;
|
||
document.getElementById('signalGraphDevice').textContent = name || mac;
|
||
drawSignalGraph();
|
||
}
|
||
|
||
function drawSignalGraph() {
|
||
const canvas = document.getElementById('signalGraph');
|
||
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);
|
||
|
||
// Draw grid
|
||
ctx.strokeStyle = '#1a1a1a';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = (height / 4) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, y);
|
||
ctx.lineTo(width, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Draw dBm labels
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '9px monospace';
|
||
ctx.fillText('-30', 2, 12);
|
||
ctx.fillText('-60', 2, height/2);
|
||
ctx.fillText('-90', 2, height - 4);
|
||
|
||
if (!trackedDevice || !signalHistory[trackedDevice] || signalHistory[trackedDevice].length < 2) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '12px sans-serif';
|
||
ctx.fillText('Click a device to track signal', width/2 - 80, height/2);
|
||
return;
|
||
}
|
||
|
||
const data = signalHistory[trackedDevice];
|
||
const stepX = width / (maxSignalPoints - 1);
|
||
|
||
// Draw signal line
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = '#00d4ff';
|
||
ctx.lineWidth = 2;
|
||
|
||
data.forEach((point, i) => {
|
||
// Map signal from -30 to -90 dBm to canvas height
|
||
const normalizedSignal = Math.max(-90, Math.min(-30, point.signal));
|
||
const y = height - ((normalizedSignal + 90) / 60) * height;
|
||
const x = i * stepX;
|
||
|
||
if (i === 0) {
|
||
ctx.moveTo(x, y);
|
||
} else {
|
||
ctx.lineTo(x, y);
|
||
}
|
||
});
|
||
ctx.stroke();
|
||
|
||
// Draw glow effect
|
||
ctx.shadowBlur = 10;
|
||
ctx.shadowColor = '#00d4ff';
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// Draw current value
|
||
const lastSignal = data[data.length - 1].signal;
|
||
ctx.fillStyle = '#00d4ff';
|
||
ctx.font = 'bold 14px monospace';
|
||
ctx.fillText(lastSignal + ' dBm', width - 70, 20);
|
||
}
|
||
|
||
// 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()]++;
|
||
}
|
||
});
|
||
|
||
// Find best 2.4 GHz channel (1, 6, or 11 preferred)
|
||
const preferred24 = [1, 6, 11];
|
||
let best24 = 1;
|
||
let minCount24 = Infinity;
|
||
preferred24.forEach(ch => {
|
||
if (channelCounts24[ch] < minCount24) {
|
||
minCount24 = channelCounts24[ch];
|
||
best24 = ch;
|
||
}
|
||
});
|
||
|
||
// Find best 5 GHz channel
|
||
let best5 = '36';
|
||
let minCount5 = Infinity;
|
||
channels5g.forEach(ch => {
|
||
if (channelCounts5[ch] < minCount5) {
|
||
minCount5 = channelCounts5[ch];
|
||
best5 = ch;
|
||
}
|
||
});
|
||
|
||
// Update UI
|
||
document.getElementById('rec24Channel').textContent = best24;
|
||
document.getElementById('rec24Reason').textContent =
|
||
minCount24 === 0 ? '(unused)' : `(${Math.round(minCount24)} networks nearby)`;
|
||
|
||
document.getElementById('rec5Channel').textContent = best5;
|
||
document.getElementById('rec5Reason').textContent =
|
||
minCount5 === 0 ? '(unused)' : `(${minCount5} networks)`;
|
||
}
|
||
|
||
// Device Correlation (WiFi <-> Bluetooth)
|
||
let deviceCorrelations = [];
|
||
|
||
function correlateDevices() {
|
||
deviceCorrelations = [];
|
||
const wifiMacs = Object.keys(wifiNetworks).concat(Object.keys(wifiClients));
|
||
const btMacs = Object.keys(btDevices || {});
|
||
|
||
// Compare OUI prefixes
|
||
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({
|
||
wifiMac: wifiMac,
|
||
btMac: btMac,
|
||
wifiName: wifiDev?.essid || wifiDev?.mac || wifiMac,
|
||
btName: btDev?.name || btMac,
|
||
manufacturer: getManufacturer(wifiOui)
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
updateCorrelationDisplay();
|
||
}
|
||
|
||
function getManufacturer(oui) {
|
||
// Simple lookup - would be expanded
|
||
const lookup = {
|
||
'00:25:DB': 'Apple', 'AC:BC:32': 'Apple', '3C:22:FB': 'Apple',
|
||
'8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung',
|
||
'54:60:09': 'Google', 'F4:F5:D8': 'Google'
|
||
};
|
||
return lookup[oui] || 'Unknown';
|
||
}
|
||
|
||
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.map(c => `
|
||
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
|
||
<span style="color: var(--accent-cyan);">📶 ${c.wifiName}</span>
|
||
<span style="color: var(--text-dim);"> ↔ </span>
|
||
<span style="color: #6495ED;">🔵 ${c.btName}</span>
|
||
<span class="correlation-badge">${c.manufacturer}</span>
|
||
</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);
|
||
if (entries.length === 0) {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">No hidden SSIDs revealed yet</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = entries.map(([bssid, ssid]) => `
|
||
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
|
||
<span class="hidden-ssid-revealed">"${ssid}"</span>
|
||
<span style="color: var(--text-dim); font-size: 9px;"> (${bssid})</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Browser Notifications
|
||
let notificationsEnabled = false;
|
||
|
||
function requestNotificationPermission() {
|
||
if ('Notification' in window) {
|
||
Notification.requestPermission().then(permission => {
|
||
notificationsEnabled = permission === 'granted';
|
||
if (notificationsEnabled) {
|
||
showInfo('🔔 Desktop notifications enabled');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function showNotification(title, body) {
|
||
if (notificationsEnabled && document.hidden) {
|
||
new Notification(title, {
|
||
body: body,
|
||
icon: '/favicon.ico',
|
||
tag: 'intercept-' + Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
// Request notification permission on load
|
||
if ('Notification' in window && Notification.permission === 'default') {
|
||
// Will request on first interaction
|
||
document.addEventListener('click', function requestOnce() {
|
||
requestNotificationPermission();
|
||
document.removeEventListener('click', requestOnce);
|
||
}, { once: true });
|
||
} else if (Notification.permission === 'granted') {
|
||
notificationsEnabled = true;
|
||
}
|
||
|
||
// Update visualizations periodically
|
||
setInterval(() => {
|
||
if (currentMode === 'wifi') {
|
||
drawSignalGraph();
|
||
drawNetworkGraph();
|
||
updateChannelRecommendation();
|
||
correlateDevices();
|
||
}
|
||
}, 2000);
|
||
|
||
// Refresh WiFi interfaces
|
||
function refreshWifiInterfaces() {
|
||
fetch('/wifi/interfaces')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const select = document.getElementById('wifiInterfaceSelect');
|
||
if (data.interfaces.length === 0) {
|
||
select.innerHTML = '<option value="">No WiFi interfaces found</option>';
|
||
} else {
|
||
select.innerHTML = data.interfaces.map(i =>
|
||
`<option value="${i.name}">${i.name} (${i.type})${i.monitor_capable ? ' [Monitor OK]' : ''}</option>`
|
||
).join('');
|
||
}
|
||
|
||
// Update tool status
|
||
const statusDiv = document.getElementById('wifiToolStatus');
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 =>
|
||
`<option value="${i.name}" ${i.name === monitorInterface ? 'selected' : ''}>${i.name} (${i.type})${i.monitor_capable ? ' [Monitor OK]' : ''}</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
|
||
function startWifiScan() {
|
||
const band = document.getElementById('wifiBand').value;
|
||
const channel = document.getElementById('wifiChannel').value;
|
||
|
||
if (!monitorInterface) {
|
||
alert('Enable monitor mode first');
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/scan/start', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
interface: monitorInterface,
|
||
band: band,
|
||
channel: channel || null
|
||
})
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
setWifiRunning(true);
|
||
startWifiStream();
|
||
} else {
|
||
alert('Error: ' + data.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 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');
|
||
};
|
||
}
|
||
|
||
// Handle discovered WiFi network (called from batched update)
|
||
function handleWifiNetworkImmediate(net) {
|
||
const isNew = !wifiNetworks[net.bssid];
|
||
wifiNetworks[net.bssid] = net;
|
||
|
||
// Track signal history for graphs
|
||
trackDeviceSignal(net.bssid, net.power);
|
||
|
||
// Check if this reveals a hidden SSID
|
||
if (net.essid && net.essid !== 'Hidden' && net.essid !== '[Hidden]') {
|
||
revealHiddenSsid(net.bssid, net.essid);
|
||
}
|
||
|
||
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;
|
||
|
||
// Track signal history for graphs
|
||
trackDeviceSignal(client.mac, client.power);
|
||
|
||
if (isNew) {
|
||
clientCount++;
|
||
document.getElementById('clientCount').textContent = clientCount;
|
||
|
||
// Check proximity watch list
|
||
checkWatchList(client.mac, 'Client');
|
||
}
|
||
|
||
// 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
|
||
});
|
||
// Note: Probe analysis updated separately if needed
|
||
}
|
||
|
||
// 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;">
|
||
<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;
|
||
}
|
||
|
||
// Add WiFi network card to output
|
||
function addWifiNetworkCard(net, isNew) {
|
||
const output = document.getElementById('output');
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) 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';
|
||
card.style.borderLeftColor = net.privacy.includes('WPA') ? 'var(--accent-orange)' :
|
||
net.privacy.includes('WEP') ? 'var(--accent-red)' :
|
||
'var(--accent-green)';
|
||
output.insertBefore(card, output.firstChild);
|
||
}
|
||
|
||
const signalStrength = parseInt(net.power) || -100;
|
||
const signalBars = Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15)));
|
||
|
||
const wpsEnabled = net.wps === '1' || net.wps === 'Yes' || (net.privacy || '').includes('WPS');
|
||
const wpsHtml = wpsEnabled ? '<span class="wps-enabled">WPS</span>' : '';
|
||
|
||
card.innerHTML = `
|
||
<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">${net.power} dBm ${'█'.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);">4-Way</button>
|
||
<button class="preset-btn pmkid-btn" onclick="capturePmkid('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px;">PMKID</button>
|
||
<button class="preset-btn" onclick="setTrackedDevice('${escapeAttr(net.bssid)}', '${escapeAttr(net.essid || net.bssid)}')" style="font-size: 10px; padding: 4px 8px; border-color: var(--accent-cyan); color: var(--accent-cyan);" title="Track signal strength">📈</button>
|
||
</div>
|
||
`;
|
||
|
||
if (autoScroll) output.scrollTop = 0;
|
||
}
|
||
|
||
// 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
|
||
function captureHandshake(bssid, channel) {
|
||
if (!confirm('Start handshake capture for ' + bssid + '? This will stop the current scan.')) {
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/handshake/capture', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({bssid: bssid, channel: channel})
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
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 {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 = '';
|
||
} 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;
|
||
}
|
||
|
||
// PMKID Capture
|
||
let activePmkid = null;
|
||
|
||
function capturePmkid(bssid, channel) {
|
||
if (!confirm('Start PMKID capture for ' + bssid + '?\\n\\nThis uses hcxdumptool to capture PMKID without needing clients.\\n\\n⚠ Only use on networks you own or have authorization to test!')) {
|
||
return;
|
||
}
|
||
|
||
const iface = document.getElementById('wifiInterfaceSelect').value;
|
||
|
||
fetch('/wifi/pmkid/capture', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, bssid: bssid, channel: channel })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
activePmkid = { bssid: bssid, file: data.file, startTime: Date.now() };
|
||
document.getElementById('pmkidPanel').style.display = 'block';
|
||
document.getElementById('pmkidTargetBssid').textContent = bssid;
|
||
document.getElementById('pmkidStatus').textContent = 'Capturing...';
|
||
document.getElementById('pmkidStatus').style.color = '#9933ff';
|
||
showInfo('PMKID capture started for ' + bssid);
|
||
|
||
// Poll for PMKID
|
||
activePmkid.pollInterval = setInterval(checkPmkidStatus, 3000);
|
||
} else {
|
||
alert('Failed to start PMKID capture: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function checkPmkidStatus() {
|
||
if (!activePmkid) return;
|
||
|
||
fetch('/wifi/pmkid/status', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ file: activePmkid.file })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.pmkid_found) {
|
||
document.getElementById('pmkidStatus').textContent = '✓ PMKID CAPTURED!';
|
||
document.getElementById('pmkidStatus').style.color = 'var(--accent-green)';
|
||
showInfo('🎉 PMKID captured! File: ' + data.file);
|
||
showNotification('🔐 PMKID Captured!', `Target: ${activePmkid.bssid}`);
|
||
clearInterval(activePmkid.pollInterval);
|
||
} else {
|
||
const elapsed = Math.floor((Date.now() - activePmkid.startTime) / 1000);
|
||
document.getElementById('pmkidStatus').textContent = 'Scanning... (' + elapsed + 's)';
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopPmkidCapture() {
|
||
if (activePmkid && activePmkid.pollInterval) {
|
||
clearInterval(activePmkid.pollInterval);
|
||
}
|
||
|
||
fetch('/wifi/pmkid/stop', { method: 'POST' })
|
||
.then(() => {
|
||
document.getElementById('pmkidStatus').textContent = 'Stopped';
|
||
document.getElementById('pmkidStatus').style.color = 'var(--text-dim)';
|
||
showInfo('PMKID capture stopped');
|
||
activePmkid = null;
|
||
});
|
||
}
|
||
|
||
// 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 };
|
||
}
|
||
|
||
btDevices[device.mac] = device;
|
||
|
||
if (isNew) {
|
||
btDeviceCount++;
|
||
document.getElementById('btDeviceCount').textContent = btDeviceCount;
|
||
playAlert();
|
||
pulseSignal();
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Add Bluetooth device card to output
|
||
function addBtDeviceCard(device, isNew) {
|
||
const output = document.getElementById('output');
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
let card = document.getElementById('bt_' + device.mac.replace(/:/g, ''));
|
||
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = 'bt_' + device.mac.replace(/:/g, '');
|
||
card.className = 'sensor-card' + (device.findmy ? ' findmy-device' : '');
|
||
const devType = device.device_type || device.type || 'other';
|
||
card.style.borderLeftColor = device.findmy ? '#007aff' :
|
||
device.tracker ? 'var(--accent-red)' :
|
||
devType === 'phone' ? 'var(--accent-cyan)' :
|
||
devType === 'audio' ? 'var(--accent-green)' :
|
||
'var(--accent-orange)';
|
||
output.insertBefore(card, output.firstChild);
|
||
}
|
||
|
||
const devType = device.device_type || device.type || 'other';
|
||
const typeIcon = {
|
||
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
|
||
'computer': '💻', 'input': '⌨️', 'other': '📶'
|
||
}[devType] || '📶';
|
||
|
||
const findMyBadge = device.findmy
|
||
? `<span class="findmy-badge">${device.findmy.icon || '📍'} ${device.findmy.network.toUpperCase()}</span>`
|
||
: '';
|
||
|
||
card.innerHTML = `
|
||
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||
<span class="device-name">${typeIcon} ${escapeHtml(device.name)} ${findMyBadge}</span>
|
||
<span style="color: #444; font-size: 10px;">${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)}</div>
|
||
</div>
|
||
${device.findmy ? `
|
||
<div class="data-item">
|
||
<div class="data-label">Find My</div>
|
||
<div class="data-value" style="color: #007aff;">${escapeHtml(device.findmy.type)}</div>
|
||
</div>` : ''}
|
||
${device.tracker && !device.findmy ? `
|
||
<div class="data-item">
|
||
<div class="data-label">Tracker</div>
|
||
<div class="data-value" style="color: var(--accent-red);">${escapeHtml(device.tracker.name)}</div>
|
||
</div>` : ''}
|
||
</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 5px;">
|
||
<button class="preset-btn" onclick="btTargetDevice('${escapeAttr(device.mac)}')" style="font-size: 10px; padding: 4px 8px;">Target</button>
|
||
<button class="preset-btn" onclick="btEnumServicesFor('${escapeAttr(device.mac)}')" style="font-size: 10px; padding: 4px 8px;">Services</button>
|
||
</div>
|
||
`;
|
||
|
||
if (autoScroll) output.scrollTop = 0;
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// ============================================
|
||
// AIRCRAFT (ADS-B) MODE FUNCTIONS
|
||
// ============================================
|
||
|
||
function checkAdsbTools() {
|
||
fetch('/adsb/tools')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Update aircraft mode panel status
|
||
const dump1090Status = document.getElementById('dump1090Status');
|
||
const rtlAdsbStatus = document.getElementById('rtlAdsbStatus');
|
||
if (dump1090Status) {
|
||
dump1090Status.textContent = data.dump1090 ? 'OK' : 'Missing';
|
||
dump1090Status.className = 'tool-status ' + (data.dump1090 ? 'ok' : 'missing');
|
||
}
|
||
if (rtlAdsbStatus) {
|
||
rtlAdsbStatus.textContent = data.rtl_adsb ? 'OK' : 'Missing';
|
||
rtlAdsbStatus.className = 'tool-status ' + (data.rtl_adsb ? 'ok' : 'missing');
|
||
}
|
||
// Update sidebar status
|
||
const dump1090Sidebar = document.getElementById('dump1090StatusSidebar');
|
||
const rtlAdsbSidebar = document.getElementById('rtlAdsbStatusSidebar');
|
||
if (dump1090Sidebar) {
|
||
dump1090Sidebar.textContent = data.dump1090 ? 'OK' : 'Missing';
|
||
dump1090Sidebar.className = 'tool-status ' + (data.dump1090 ? 'ok' : 'missing');
|
||
}
|
||
if (rtlAdsbSidebar) {
|
||
rtlAdsbSidebar.textContent = data.rtl_adsb ? 'OK' : 'Missing';
|
||
rtlAdsbSidebar.className = 'tool-status ' + (data.rtl_adsb ? 'ok' : 'missing');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Leaflet map for aircraft tracking
|
||
let aircraftMap = null;
|
||
let aircraftMarkers = {};
|
||
let aircraftClusterGroup = null;
|
||
let clusteringEnabled = false;
|
||
let mapRefreshInterval = null;
|
||
|
||
function initAircraftRadar() {
|
||
const mapContainer = document.getElementById('aircraftMap');
|
||
if (!mapContainer || aircraftMap) return;
|
||
|
||
// Initialize Leaflet map
|
||
aircraftMap = L.map('aircraftMap', {
|
||
center: [51.5, -0.1], // Default to London
|
||
zoom: 5,
|
||
zoomControl: true,
|
||
attributionControl: true
|
||
});
|
||
|
||
// Add OpenStreetMap tiles (will be inverted by CSS for dark theme)
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap',
|
||
maxZoom: 18
|
||
}).addTo(aircraftMap);
|
||
|
||
// Initialize cluster group (but don't add to map yet)
|
||
aircraftClusterGroup = L.markerClusterGroup({
|
||
maxClusterRadius: 50,
|
||
spiderfyOnMaxZoom: true,
|
||
showCoverageOnHover: false,
|
||
iconCreateFunction: function(cluster) {
|
||
const count = cluster.getChildCount();
|
||
let size = 'small';
|
||
if (count > 10) size = 'medium';
|
||
if (count > 25) size = 'large';
|
||
|
||
return L.divIcon({
|
||
html: '<div class="marker-cluster marker-cluster-' + size + '">' + count + '</div>',
|
||
className: '',
|
||
iconSize: L.point(40, 40)
|
||
});
|
||
}
|
||
});
|
||
|
||
// Update time display
|
||
updateRadarTime();
|
||
setInterval(updateRadarTime, 1000);
|
||
|
||
// Refresh aircraft markers every second
|
||
if (!mapRefreshInterval) {
|
||
mapRefreshInterval = setInterval(updateAircraftMarkers, 1000);
|
||
}
|
||
|
||
// Setup interaction tracking
|
||
setupMapInteraction();
|
||
|
||
// Initial update
|
||
updateAircraftMarkers();
|
||
}
|
||
|
||
function toggleAircraftClustering() {
|
||
clusteringEnabled = document.getElementById('adsbEnableClustering').checked;
|
||
|
||
if (!aircraftMap || !aircraftClusterGroup) return;
|
||
|
||
if (clusteringEnabled) {
|
||
// Move all markers to cluster group
|
||
Object.values(aircraftMarkers).forEach(marker => {
|
||
if (aircraftMap.hasLayer(marker)) {
|
||
aircraftMap.removeLayer(marker);
|
||
}
|
||
aircraftClusterGroup.addLayer(marker);
|
||
});
|
||
aircraftMap.addLayer(aircraftClusterGroup);
|
||
} else {
|
||
// Move all markers back to map directly
|
||
aircraftClusterGroup.clearLayers();
|
||
aircraftMap.removeLayer(aircraftClusterGroup);
|
||
Object.values(aircraftMarkers).forEach(marker => {
|
||
marker.addTo(aircraftMap);
|
||
});
|
||
}
|
||
}
|
||
|
||
function applyAircraftFilter() {
|
||
// Clear all markers and redraw with new filter
|
||
Object.keys(aircraftMarkers).forEach(icao => {
|
||
if (clusteringEnabled && aircraftClusterGroup) {
|
||
aircraftClusterGroup.removeLayer(aircraftMarkers[icao]);
|
||
} else if (aircraftMap) {
|
||
aircraftMap.removeLayer(aircraftMarkers[icao]);
|
||
}
|
||
delete aircraftMarkers[icao];
|
||
delete aircraftMarkerState[icao];
|
||
});
|
||
// Trail lines should also be cleared for filtered-out aircraft
|
||
Object.keys(aircraftTrailLines).forEach(icao => {
|
||
if (aircraftMap) {
|
||
aircraftMap.removeLayer(aircraftTrailLines[icao]);
|
||
}
|
||
delete aircraftTrailLines[icao];
|
||
});
|
||
updateAircraftMarkers();
|
||
}
|
||
|
||
function updateRadarTime() {
|
||
const now = new Date();
|
||
const time = now.toTimeString().substring(0, 8);
|
||
const el = document.getElementById('radarTime');
|
||
if (el) el.textContent = time;
|
||
}
|
||
|
||
function createAircraftIcon(heading, emergency, customColor) {
|
||
const color = customColor || (emergency ? '#ff4444' : '#00d4ff');
|
||
const rotation = heading || 0;
|
||
|
||
return L.divIcon({
|
||
className: 'aircraft-marker' + (emergency ? ' squawk-emergency' : ''),
|
||
html: `<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color};">
|
||
<path fill="currentColor" d="M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z"/>
|
||
</svg>`,
|
||
iconSize: [24, 24],
|
||
iconAnchor: [12, 12]
|
||
});
|
||
}
|
||
|
||
let aircraftTrailLines = {}; // ICAO -> Leaflet polyline
|
||
let aircraftMarkerState = {}; // Cache marker state to avoid unnecessary updates
|
||
const MAX_AIRCRAFT_MARKERS = 150; // Limit markers to prevent browser freeze
|
||
|
||
function buildTooltipText(aircraft, showLabels, showAltitude) {
|
||
if (!showLabels && !showAltitude) return '';
|
||
let text = '';
|
||
if (showLabels && aircraft.callsign) text = aircraft.callsign;
|
||
if (showAltitude && aircraft.altitude) {
|
||
if (text) text += ' ';
|
||
text += 'FL' + Math.round(aircraft.altitude / 100).toString().padStart(3, '0');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function buildPopupContent(icao) {
|
||
const aircraft = adsbAircraft[icao];
|
||
if (!aircraft) return '';
|
||
|
||
const squawkInfo = checkSquawkCode(aircraft);
|
||
const militaryInfo = isMilitaryAircraft(icao, aircraft.callsign);
|
||
|
||
let content = '<div class="aircraft-popup">';
|
||
if (militaryInfo.military) {
|
||
content += `<div style="background: #556b2f; color: white; padding: 2px 8px; border-radius: 3px; font-size: 10px; margin-bottom: 5px;">🎖️ MILITARY${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}</div>`;
|
||
}
|
||
if (squawkInfo) {
|
||
content += `<div style="background: ${squawkInfo.color}; color: white; padding: 4px 8px; border-radius: 3px; font-size: 11px; margin-bottom: 5px; font-weight: bold;">⚠️ ${squawkInfo.name}</div>`;
|
||
}
|
||
content += `<div class="callsign">${aircraft.callsign || icao}</div>`;
|
||
if (aircraft.altitude) {
|
||
content += `<div class="data-row"><span class="label">Altitude:</span><span class="value">${aircraft.altitude.toLocaleString()} ft</span></div>`;
|
||
}
|
||
if (aircraft.speed) {
|
||
content += `<div class="data-row"><span class="label">Speed:</span><span class="value">${aircraft.speed} kts</span></div>`;
|
||
}
|
||
if (aircraft.heading !== undefined) {
|
||
content += `<div class="data-row"><span class="label">Heading:</span><span class="value">${aircraft.heading}°</span></div>`;
|
||
}
|
||
if (aircraft.squawk) {
|
||
const squawkStyle = squawkInfo ? `color: ${squawkInfo.color}; font-weight: bold;` : '';
|
||
content += `<div class="data-row"><span class="label">Squawk:</span><span class="value" style="${squawkStyle}">${aircraft.squawk}</span></div>`;
|
||
}
|
||
content += '</div>';
|
||
return content;
|
||
}
|
||
|
||
function updateAircraftMarkers() {
|
||
if (!aircraftMap) return;
|
||
|
||
const showLabels = document.getElementById('adsbShowLabels')?.checked;
|
||
const showAltitude = document.getElementById('adsbShowAltitude')?.checked;
|
||
const showTrails = document.getElementById('adsbShowTrails')?.checked ?? true;
|
||
const aircraftFilter = document.getElementById('adsbAircraftFilter')?.value || 'all';
|
||
const currentIds = new Set();
|
||
|
||
// Sort aircraft by altitude and limit to prevent DOM explosion
|
||
const sortedAircraft = Object.entries(adsbAircraft)
|
||
.filter(([_, a]) => a.lat != null && a.lon != null)
|
||
.filter(([icao, a]) => {
|
||
if (aircraftFilter === 'all') return true;
|
||
const militaryInfo = isMilitaryAircraft(icao, a.callsign);
|
||
const squawkInfo = checkSquawkCode(a);
|
||
if (aircraftFilter === 'military') return militaryInfo.military;
|
||
if (aircraftFilter === 'civil') return !militaryInfo.military;
|
||
if (aircraftFilter === 'emergency') return !!squawkInfo;
|
||
return true;
|
||
})
|
||
.sort((a, b) => (b[1].altitude || 0) - (a[1].altitude || 0))
|
||
.slice(0, MAX_AIRCRAFT_MARKERS);
|
||
|
||
// Update or create markers for each aircraft
|
||
sortedAircraft.forEach(([icao, aircraft]) => {
|
||
currentIds.add(icao);
|
||
|
||
// Update trail history
|
||
updateAircraftTrail(icao, aircraft.lat, aircraft.lon);
|
||
|
||
// Check for emergency squawk codes
|
||
const squawkInfo = checkSquawkCode(aircraft);
|
||
|
||
// Check for military aircraft
|
||
const militaryInfo = isMilitaryAircraft(icao, aircraft.callsign);
|
||
aircraft.military = militaryInfo.military;
|
||
|
||
// Determine icon color
|
||
let iconColor = '#00d4ff'; // Default cyan
|
||
if (squawkInfo) iconColor = squawkInfo.color;
|
||
else if (militaryInfo.military) iconColor = '#556b2f'; // Olive drab
|
||
else if (aircraft.emergency) iconColor = '#ff4444';
|
||
|
||
// Round heading to reduce icon recreations
|
||
const roundedHeading = Math.round((aircraft.heading || 0) / 5) * 5;
|
||
|
||
// Check if icon state actually changed
|
||
const prevState = aircraftMarkerState[icao] || {};
|
||
const iconChanged = prevState.heading !== roundedHeading ||
|
||
prevState.color !== iconColor ||
|
||
prevState.emergency !== (squawkInfo || aircraft.emergency);
|
||
|
||
if (aircraftMarkers[icao]) {
|
||
// Update existing marker - position is cheap
|
||
aircraftMarkers[icao].setLatLng([aircraft.lat, aircraft.lon]);
|
||
// Only update icon if it actually changed
|
||
if (iconChanged) {
|
||
const icon = createAircraftIcon(roundedHeading, squawkInfo || aircraft.emergency, iconColor);
|
||
aircraftMarkers[icao].setIcon(icon);
|
||
aircraftMarkerState[icao] = { heading: roundedHeading, color: iconColor, emergency: squawkInfo || aircraft.emergency };
|
||
}
|
||
} else {
|
||
const icon = createAircraftIcon(roundedHeading, squawkInfo || aircraft.emergency, iconColor);
|
||
aircraftMarkerState[icao] = { heading: roundedHeading, color: iconColor, emergency: squawkInfo || aircraft.emergency };
|
||
// Create new marker
|
||
const marker = L.marker([aircraft.lat, aircraft.lon], { icon: icon });
|
||
if (clusteringEnabled && aircraftClusterGroup) {
|
||
aircraftClusterGroup.addLayer(marker);
|
||
} else {
|
||
marker.addTo(aircraftMap);
|
||
}
|
||
aircraftMarkers[icao] = marker;
|
||
}
|
||
|
||
// Draw flight trail
|
||
if (showTrails && aircraftTrails[icao] && aircraftTrails[icao].length > 1) {
|
||
const trailCoords = aircraftTrails[icao].map(p => [p.lat, p.lon]);
|
||
|
||
if (aircraftTrailLines[icao]) {
|
||
aircraftTrailLines[icao].setLatLngs(trailCoords);
|
||
} else {
|
||
aircraftTrailLines[icao] = L.polyline(trailCoords, {
|
||
color: militaryInfo.military ? '#556b2f' : '#00d4ff',
|
||
weight: 2,
|
||
opacity: 0.6,
|
||
dashArray: '5, 5'
|
||
}).addTo(aircraftMap);
|
||
}
|
||
} else if (aircraftTrailLines[icao]) {
|
||
aircraftMap.removeLayer(aircraftTrailLines[icao]);
|
||
delete aircraftTrailLines[icao];
|
||
}
|
||
|
||
// Only update popup/tooltip if data changed (expensive operations)
|
||
const tooltipText = buildTooltipText(aircraft, showLabels, showAltitude);
|
||
const prevTooltip = prevState.tooltipText;
|
||
|
||
// Only rebind tooltip if content changed
|
||
if (tooltipText !== prevTooltip) {
|
||
aircraftMarkerState[icao].tooltipText = tooltipText;
|
||
aircraftMarkers[icao].unbindTooltip();
|
||
if (tooltipText) {
|
||
aircraftMarkers[icao].bindTooltip(tooltipText, {
|
||
permanent: true,
|
||
direction: 'right',
|
||
className: 'aircraft-tooltip'
|
||
});
|
||
}
|
||
}
|
||
|
||
// Bind popup lazily - content is built on open, not every update
|
||
if (!aircraftMarkers[icao]._hasPopupBound) {
|
||
aircraftMarkers[icao].bindPopup(() => buildPopupContent(icao));
|
||
aircraftMarkers[icao]._hasPopupBound = true;
|
||
}
|
||
});
|
||
|
||
// Remove markers for aircraft no longer tracked
|
||
Object.keys(aircraftMarkers).forEach(icao => {
|
||
if (!currentIds.has(icao)) {
|
||
if (clusteringEnabled && aircraftClusterGroup) {
|
||
aircraftClusterGroup.removeLayer(aircraftMarkers[icao]);
|
||
} else {
|
||
aircraftMap.removeLayer(aircraftMarkers[icao]);
|
||
}
|
||
// Also remove trail
|
||
if (aircraftTrailLines[icao]) {
|
||
aircraftMap.removeLayer(aircraftTrailLines[icao]);
|
||
delete aircraftTrailLines[icao];
|
||
}
|
||
delete aircraftTrails[icao];
|
||
delete aircraftMarkers[icao];
|
||
delete aircraftMarkerState[icao];
|
||
delete activeSquawkAlerts[icao];
|
||
}
|
||
});
|
||
|
||
// Update status display
|
||
const aircraftCount = Object.keys(adsbAircraft).length;
|
||
document.getElementById('radarStatus').textContent = isAdsbRunning ?
|
||
`TRACKING ${aircraftCount}` : 'STANDBY';
|
||
document.getElementById('aircraftCount').textContent = aircraftCount;
|
||
|
||
// Update map center display
|
||
const center = aircraftMap.getCenter();
|
||
document.getElementById('mapCenter').textContent =
|
||
`${center.lat.toFixed(2)}, ${center.lng.toFixed(2)}`;
|
||
|
||
// Auto-fit bounds if we have aircraft (throttled to avoid performance issues)
|
||
const now = Date.now();
|
||
if (aircraftCount > 0 && !aircraftMap._userInteracted &&
|
||
(!aircraftMap._lastFitBounds || now - aircraftMap._lastFitBounds > 5000)) {
|
||
const bounds = [];
|
||
Object.values(adsbAircraft).forEach(a => {
|
||
if (a.lat !== undefined && a.lon !== undefined) {
|
||
bounds.push([a.lat, a.lon]);
|
||
}
|
||
});
|
||
if (bounds.length > 0) {
|
||
aircraftMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 });
|
||
aircraftMap._lastFitBounds = now;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Track user interaction to stop auto-fitting
|
||
function setupMapInteraction() {
|
||
if (aircraftMap) {
|
||
aircraftMap.on('dragstart zoomstart', () => {
|
||
aircraftMap._userInteracted = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Calculate distance between two points in nautical miles
|
||
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
|
||
const R = 3440.065; // Earth radius in nautical 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 ADS-B statistics
|
||
function updateAdsbStatistics(icao, aircraft) {
|
||
if (!aircraft.lat || !aircraft.lon) return;
|
||
|
||
// Track unique aircraft
|
||
adsbStats.totalAircraftSeen.add(icao);
|
||
|
||
// Calculate distance from observer
|
||
const distance = calculateDistanceNm(
|
||
observerLocation.lat, observerLocation.lon,
|
||
aircraft.lat, aircraft.lon
|
||
);
|
||
|
||
// Update max range if this is further
|
||
if (distance > adsbStats.maxRange) {
|
||
adsbStats.maxRange = distance;
|
||
adsbStats.maxRangeAircraft = aircraft.callsign || icao;
|
||
}
|
||
|
||
// Track hourly aircraft count
|
||
const hour = new Date().getHours();
|
||
if (!adsbStats.hourlyCount[hour]) {
|
||
adsbStats.hourlyCount[hour] = new Set();
|
||
}
|
||
adsbStats.hourlyCount[hour].add(icao);
|
||
|
||
// Update messages per second calculation
|
||
const now = Date.now();
|
||
adsbStats.messageTimestamps.push(now);
|
||
// Keep only last 5 seconds of timestamps
|
||
adsbStats.messageTimestamps = adsbStats.messageTimestamps.filter(t => now - t < 5000);
|
||
adsbStats.messagesPerSecond = adsbStats.messageTimestamps.length / 5;
|
||
|
||
// Update stats display
|
||
updateStatsDisplay();
|
||
}
|
||
|
||
// Update the statistics display
|
||
function updateStatsDisplay() {
|
||
const maxRangeEl = document.getElementById('adsbMaxRange');
|
||
const totalSeenEl = document.getElementById('adsbTotalSeen');
|
||
const msgRateEl = document.getElementById('adsbMsgRate');
|
||
const busiestHourEl = document.getElementById('adsbBusiestHour');
|
||
|
||
if (maxRangeEl) {
|
||
maxRangeEl.textContent = `${adsbStats.maxRange.toFixed(1)} nm`;
|
||
if (adsbStats.maxRangeAircraft) {
|
||
maxRangeEl.title = `Aircraft: ${adsbStats.maxRangeAircraft}`;
|
||
}
|
||
}
|
||
if (totalSeenEl) {
|
||
totalSeenEl.textContent = adsbStats.totalAircraftSeen.size;
|
||
}
|
||
if (msgRateEl) {
|
||
msgRateEl.textContent = `${adsbStats.messagesPerSecond.toFixed(1)}/s`;
|
||
}
|
||
if (busiestHourEl) {
|
||
let busiestHour = 0;
|
||
let maxCount = 0;
|
||
Object.entries(adsbStats.hourlyCount).forEach(([hour, aircraftSet]) => {
|
||
if (aircraftSet.size > maxCount) {
|
||
maxCount = aircraftSet.size;
|
||
busiestHour = hour;
|
||
}
|
||
});
|
||
busiestHourEl.textContent = maxCount > 0 ? `${busiestHour}:00 (${maxCount})` : '--';
|
||
}
|
||
}
|
||
|
||
// Draw range rings on the map
|
||
function drawRangeRings() {
|
||
if (!aircraftMap) return;
|
||
|
||
// Remove existing rings
|
||
if (rangeRingsLayer) {
|
||
aircraftMap.removeLayer(rangeRingsLayer);
|
||
}
|
||
|
||
const showRings = document.getElementById('adsbShowRangeRings')?.checked;
|
||
if (!showRings) return;
|
||
|
||
rangeRingsLayer = L.layerGroup();
|
||
|
||
// Range ring distances in nautical miles
|
||
const distances = [25, 50, 100, 150, 200];
|
||
|
||
distances.forEach(nm => {
|
||
// Convert nm to meters for Leaflet circle
|
||
const meters = nm * 1852;
|
||
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
|
||
radius: meters,
|
||
color: '#00d4ff',
|
||
fillColor: 'transparent',
|
||
fillOpacity: 0,
|
||
weight: 1,
|
||
opacity: 0.4,
|
||
dashArray: '5, 5'
|
||
});
|
||
|
||
// Add label
|
||
const labelLatLng = L.latLng(
|
||
observerLocation.lat + (nm * 0.0166), // Approx degrees per nm
|
||
observerLocation.lon
|
||
);
|
||
|
||
const label = L.marker(labelLatLng, {
|
||
icon: L.divIcon({
|
||
className: 'range-ring-label',
|
||
html: `<span style="color: #00d4ff; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${nm} nm</span>`,
|
||
iconSize: [40, 12],
|
||
iconAnchor: [20, 6]
|
||
})
|
||
});
|
||
|
||
rangeRingsLayer.addLayer(circle);
|
||
rangeRingsLayer.addLayer(label);
|
||
});
|
||
|
||
// Add observer marker
|
||
if (observerMarkerAdsb) {
|
||
aircraftMap.removeLayer(observerMarkerAdsb);
|
||
}
|
||
observerMarkerAdsb = L.marker([observerLocation.lat, observerLocation.lon], {
|
||
icon: L.divIcon({
|
||
className: 'observer-marker',
|
||
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
|
||
iconSize: [12, 12],
|
||
iconAnchor: [6, 6]
|
||
})
|
||
}).bindPopup('Your Location').addTo(aircraftMap);
|
||
|
||
rangeRingsLayer.addTo(aircraftMap);
|
||
}
|
||
|
||
// Update observer location from input fields
|
||
function updateObserverLocation() {
|
||
const latInput = document.getElementById('adsbObsLat');
|
||
const lonInput = document.getElementById('adsbObsLon');
|
||
|
||
if (latInput && lonInput) {
|
||
const lat = parseFloat(latInput.value);
|
||
const lon = parseFloat(lonInput.value);
|
||
|
||
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||
observerLocation.lat = lat;
|
||
observerLocation.lon = lon;
|
||
|
||
// Center map on location
|
||
if (aircraftMap) {
|
||
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
|
||
aircraftMap._userInteracted = true;
|
||
}
|
||
|
||
// Redraw range rings
|
||
drawRangeRings();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get user's geolocation (only works on HTTPS or localhost)
|
||
function getAdsbGeolocation() {
|
||
if (!navigator.geolocation) {
|
||
alert('Geolocation is not supported by your browser');
|
||
return;
|
||
}
|
||
|
||
// Check if we're on a secure context
|
||
if (!window.isSecureContext) {
|
||
alert('GPS location requires HTTPS. Please enter your coordinates manually in the lat/lon fields above.');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('adsbGeolocateBtn');
|
||
if (btn) btn.textContent = '📍 Locating...';
|
||
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => {
|
||
observerLocation.lat = position.coords.latitude;
|
||
observerLocation.lon = position.coords.longitude;
|
||
|
||
// Update input fields
|
||
const latInput = document.getElementById('adsbObsLat');
|
||
const lonInput = document.getElementById('adsbObsLon');
|
||
if (latInput) latInput.value = observerLocation.lat.toFixed(4);
|
||
if (lonInput) lonInput.value = observerLocation.lon.toFixed(4);
|
||
|
||
// Center map on location
|
||
if (aircraftMap) {
|
||
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
|
||
aircraftMap._userInteracted = true;
|
||
}
|
||
|
||
// Redraw range rings
|
||
drawRangeRings();
|
||
|
||
if (btn) btn.textContent = '📍 Use GPS Location';
|
||
showInfo(`Location set: ${observerLocation.lat.toFixed(4)}, ${observerLocation.lon.toFixed(4)}`);
|
||
},
|
||
(error) => {
|
||
if (btn) btn.textContent = '📍 Use GPS Location';
|
||
alert('Unable to get location. Please enter coordinates manually.\n\nError: ' + error.message);
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 10000 }
|
||
);
|
||
}
|
||
|
||
// Reset ADS-B statistics
|
||
function resetAdsbStats() {
|
||
adsbStats = {
|
||
totalAircraftSeen: new Set(),
|
||
maxRange: 0,
|
||
maxRangeAircraft: null,
|
||
hourlyCount: {},
|
||
messagesPerSecond: 0,
|
||
messageTimestamps: [],
|
||
sessionStart: Date.now()
|
||
};
|
||
updateStatsDisplay();
|
||
}
|
||
|
||
function startAdsbScan() {
|
||
const gain = document.getElementById('adsbGain').value;
|
||
const device = getSelectedDevice();
|
||
|
||
fetch('/adsb/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ gain, device })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
isAdsbRunning = true;
|
||
document.getElementById('startAdsbBtn').style.display = 'none';
|
||
document.getElementById('stopAdsbBtn').style.display = 'block';
|
||
document.getElementById('statusDot').className = 'status-dot active';
|
||
document.getElementById('statusText').textContent = 'ADS-B Tracking';
|
||
resetAdsbStats(); // Reset statistics for new session
|
||
adsbStats.sessionStart = Date.now();
|
||
startAdsbStream();
|
||
drawRangeRings(); // Draw range rings if enabled
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopAdsbScan() {
|
||
fetch('/adsb/stop', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
isAdsbRunning = false;
|
||
document.getElementById('startAdsbBtn').style.display = 'block';
|
||
document.getElementById('stopAdsbBtn').style.display = 'none';
|
||
document.getElementById('statusDot').className = 'status-dot';
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
if (adsbEventSource) {
|
||
adsbEventSource.close();
|
||
adsbEventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Batching state for aircraft updates to prevent browser freeze
|
||
let pendingAircraftUpdate = false;
|
||
let pendingAircraftData = [];
|
||
|
||
function scheduleAircraftUIUpdate() {
|
||
if (pendingAircraftUpdate) return;
|
||
pendingAircraftUpdate = true;
|
||
requestAnimationFrame(() => {
|
||
updateAdsbStats();
|
||
updateAircraftMarkers();
|
||
// Batch output updates - only show last 10 to prevent DOM explosion
|
||
const toOutput = pendingAircraftData.slice(-10);
|
||
pendingAircraftData = [];
|
||
toOutput.forEach(data => addAircraftToOutput(data));
|
||
pendingAircraftUpdate = false;
|
||
});
|
||
}
|
||
|
||
function startAdsbStream() {
|
||
if (adsbEventSource) adsbEventSource.close();
|
||
adsbEventSource = new EventSource('/adsb/stream');
|
||
|
||
adsbEventSource.onmessage = function(e) {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'aircraft') {
|
||
adsbAircraft[data.icao] = {
|
||
...adsbAircraft[data.icao],
|
||
...data,
|
||
lastSeen: Date.now()
|
||
};
|
||
adsbMsgCount++;
|
||
pendingAircraftData.push(data);
|
||
// Check for military/emergency aircraft and alert
|
||
checkAndAlertAircraft(data.icao, adsbAircraft[data.icao]);
|
||
// Update statistics
|
||
updateAdsbStatistics(data.icao, adsbAircraft[data.icao]);
|
||
// Use batched update instead of immediate
|
||
scheduleAircraftUIUpdate();
|
||
}
|
||
};
|
||
|
||
// Periodic cleanup of stale aircraft
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
let needsUpdate = false;
|
||
Object.keys(adsbAircraft).forEach(icao => {
|
||
if (now - adsbAircraft[icao].lastSeen > 60000) {
|
||
delete adsbAircraft[icao];
|
||
needsUpdate = true;
|
||
}
|
||
});
|
||
if (needsUpdate) {
|
||
scheduleAircraftUIUpdate();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function updateAdsbStats() {
|
||
document.getElementById('aircraftCount').textContent = Object.keys(adsbAircraft).length;
|
||
document.getElementById('adsbMsgCount').textContent = adsbMsgCount;
|
||
document.getElementById('icaoCount').textContent = Object.keys(adsbAircraft).length;
|
||
}
|
||
|
||
function addAircraftToOutput(aircraft) {
|
||
const output = document.getElementById('output');
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Check if card for this ICAO already exists
|
||
let card = output.querySelector(`[data-icao="${aircraft.icao}"]`);
|
||
const isNew = !card;
|
||
|
||
if (isNew) {
|
||
card = document.createElement('div');
|
||
card.className = 'aircraft-card';
|
||
card.setAttribute('data-icao', aircraft.icao);
|
||
}
|
||
|
||
card.innerHTML = `
|
||
<div class="aircraft-icon" style="--heading: ${aircraft.heading || 0}deg;">✈️</div>
|
||
<div class="aircraft-info">
|
||
<div class="aircraft-callsign">${aircraft.callsign || aircraft.icao}</div>
|
||
<div class="aircraft-data">ICAO: <span>${aircraft.icao}</span></div>
|
||
<div class="aircraft-data">Alt: <span>${aircraft.altitude ? aircraft.altitude + ' ft' : 'N/A'}</span></div>
|
||
<div class="aircraft-data">Speed: <span>${aircraft.speed ? aircraft.speed + ' kts' : 'N/A'}</span></div>
|
||
<div class="aircraft-data">Heading: <span>${aircraft.heading ? aircraft.heading + '°' : 'N/A'}</span></div>
|
||
</div>
|
||
`;
|
||
|
||
if (isNew) {
|
||
output.insertBefore(card, output.firstChild);
|
||
// Limit cards
|
||
while (output.children.length > 50) {
|
||
output.removeChild(output.lastChild);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// SATELLITE MODE FUNCTIONS
|
||
// ============================================
|
||
|
||
function switchSatelliteTab(tab) {
|
||
document.querySelectorAll('.satellite-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.satellite-content').forEach(c => c.classList.remove('active'));
|
||
document.querySelector(`.satellite-tab:nth-child(${tab === 'predictor' ? 1 : 2})`).classList.add('active');
|
||
document.getElementById(tab === 'predictor' ? 'predictorTab' : 'iridiumTab').classList.add('active');
|
||
|
||
// Toggle Iridium burst log visibility
|
||
document.getElementById('iridiumBurstLog').style.display = tab === 'iridium' ? 'block' : 'none';
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
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}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||
maxZoom: 19
|
||
}).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();
|
||
}
|
||
|
||
function popoutSatellite() {
|
||
document.getElementById('satellitePopout').classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Initialize popout canvas
|
||
setTimeout(() => {
|
||
const canvas = document.getElementById('polarPlotCanvasPopout');
|
||
if (canvas) {
|
||
const container = canvas.parentElement;
|
||
const size = Math.min(container.offsetWidth, container.offsetHeight - 50);
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
drawPolarPlotPopout(selectedPass);
|
||
}
|
||
|
||
// Render pass list in popout with working click handlers
|
||
renderPassListPopout();
|
||
|
||
// Show countdown in popout if passes exist
|
||
if (satellitePasses.length > 0) {
|
||
document.getElementById('satelliteCountdownPopout').style.display = 'block';
|
||
updateCountdownDisplay('Popout');
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
function renderPassListPopout() {
|
||
const container = document.getElementById('passListPopout');
|
||
container.innerHTML = '';
|
||
|
||
if (satellitePasses.length === 0) {
|
||
container.innerHTML = '<div style="color: #666; text-align: center; padding: 30px;">No passes found.</div>';
|
||
return;
|
||
}
|
||
|
||
satellitePasses.forEach((pass, index) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'pass-card' + (pass === selectedPass ? ' active' : '');
|
||
card.onclick = () => selectPassPopout(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 selectPassPopout(index) {
|
||
selectedPass = satellitePasses[index];
|
||
selectedPassIndex = index;
|
||
|
||
// Update active state in popout
|
||
document.querySelectorAll('#passListPopout .pass-card').forEach((card, i) => {
|
||
card.classList.toggle('active', i === index);
|
||
});
|
||
|
||
// Also update main list
|
||
document.querySelectorAll('#passList .pass-card').forEach((card, i) => {
|
||
card.classList.toggle('active', i === index);
|
||
});
|
||
|
||
// Update polar plot in popout
|
||
drawPolarPlotPopout(selectedPass);
|
||
|
||
// Update countdown
|
||
updateSatelliteCountdown();
|
||
}
|
||
|
||
function closeSatellitePopout() {
|
||
document.getElementById('satellitePopout').classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function drawPolarPlotPopout(pass) {
|
||
const canvas = document.getElementById('polarPlotCanvasPopout');
|
||
if (!canvas) return;
|
||
// Same as drawPolarPlot but for popout canvas
|
||
const ctx = canvas.getContext('2d');
|
||
const size = canvas.width;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = size / 2 - 40;
|
||
|
||
ctx.fillStyle = '#0a0a0a';
|
||
ctx.fillRect(0, 0, size, size);
|
||
|
||
// Elevation rings
|
||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
|
||
ctx.lineWidth = 1;
|
||
for (let el = 0; el <= 90; el += 15) {
|
||
const r = radius * (90 - el) / 90;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
if (el > 0 && el % 30 === 0) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '12px JetBrains Mono';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(el + '°', cx, cy - r + 14);
|
||
}
|
||
}
|
||
|
||
// Azimuth lines
|
||
for (let az = 0; az < 360; az += 30) {
|
||
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();
|
||
}
|
||
|
||
// Cardinals
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.font = 'bold 16px Rajdhani';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('N', cx, cy - radius - 12);
|
||
ctx.fillStyle = '#888';
|
||
ctx.fillText('S', cx, cy + radius + 20);
|
||
ctx.fillText('E', cx + radius + 16, cy + 5);
|
||
ctx.fillText('W', cx - radius - 16, cy + 5);
|
||
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
if (pass && pass.trajectory) {
|
||
ctx.strokeStyle = pass.color || '#00ff00';
|
||
ctx.lineWidth = 3;
|
||
ctx.setLineDash([8, 4]);
|
||
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([]);
|
||
|
||
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;
|
||
ctx.fillStyle = pass.color || '#00ff00';
|
||
ctx.beginPath();
|
||
ctx.arc(cx + Math.sin(maxRad) * maxR, cy - Math.cos(maxRad) * maxR, 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '14px JetBrains Mono';
|
||
ctx.fillText(pass.satellite, cx + Math.sin(maxRad) * maxR + 15, cy - Math.cos(maxRad) * maxR - 10);
|
||
}
|
||
}
|
||
|
||
// Iridium functions
|
||
function checkIridiumTools() {
|
||
fetch('/iridium/tools')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const status = document.getElementById('iridiumExtractorStatus');
|
||
status.textContent = data.available ? 'OK' : 'Not found';
|
||
status.className = 'tool-status ' + (data.available ? 'ok' : 'missing');
|
||
});
|
||
}
|
||
|
||
function startIridiumCapture() {
|
||
const freq = document.getElementById('iridiumFreq').value;
|
||
const gain = document.getElementById('iridiumGain').value;
|
||
const sampleRate = document.getElementById('iridiumSampleRate').value;
|
||
const device = getSelectedDevice();
|
||
|
||
fetch('/iridium/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ freq, gain, sampleRate, device })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
isIridiumRunning = true;
|
||
document.getElementById('startIridiumBtn').style.display = 'none';
|
||
document.getElementById('stopIridiumBtn').style.display = 'block';
|
||
document.getElementById('statusDot').className = 'status-dot active';
|
||
document.getElementById('statusText').textContent = 'Iridium Capture';
|
||
startIridiumStream();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopIridiumCapture() {
|
||
fetch('/iridium/stop', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
isIridiumRunning = false;
|
||
document.getElementById('startIridiumBtn').style.display = 'block';
|
||
document.getElementById('stopIridiumBtn').style.display = 'none';
|
||
document.getElementById('statusDot').className = 'status-dot';
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
if (iridiumEventSource) {
|
||
iridiumEventSource.close();
|
||
iridiumEventSource = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function startIridiumStream() {
|
||
if (iridiumEventSource) iridiumEventSource.close();
|
||
iridiumEventSource = new EventSource('/iridium/stream');
|
||
|
||
iridiumEventSource.onmessage = function(e) {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'burst') {
|
||
iridiumBursts.unshift(data);
|
||
document.getElementById('burstCount').textContent = iridiumBursts.length;
|
||
addBurstToLog(data);
|
||
}
|
||
};
|
||
}
|
||
|
||
function addBurstToLog(burst) {
|
||
const container = document.getElementById('burstList');
|
||
const placeholder = container.querySelector('div[style*="color: #666"]');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'burst-card';
|
||
card.innerHTML = `
|
||
<div class="burst-time">${burst.time}</div>
|
||
<div class="burst-freq">${burst.frequency} MHz</div>
|
||
<div class="burst-data">${burst.data || 'No payload data'}</div>
|
||
`;
|
||
container.insertBefore(card, container.firstChild);
|
||
|
||
while (container.children.length > 100) {
|
||
container.removeChild(container.lastChild);
|
||
}
|
||
}
|
||
|
||
function clearIridiumLog() {
|
||
iridiumBursts = [];
|
||
document.getElementById('burstCount').textContent = '0';
|
||
document.getElementById('burstList').innerHTML = '<div style="color: #666; text-align: center; padding: 30px; font-size: 11px;">Iridium bursts will appear here when detected.</div>';
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
localStorage.setItem('intercept-theme', newTheme);
|
||
}
|
||
|
||
// Load saved theme on page load
|
||
(function() {
|
||
const savedTheme = localStorage.getItem('intercept-theme');
|
||
if (savedTheme === 'light') {
|
||
document.documentElement.setAttribute('data-theme', 'light');
|
||
}
|
||
})();
|
||
|
||
// 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();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- 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">Aircraft being tracked</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 detected</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 - ADS-B tracker</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>
|
||
</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 Mode</h3>
|
||
<ul class="tip-list">
|
||
<li>Tracks aircraft via ADS-B using dump1090 or rtl_adsb</li>
|
||
<li>Interactive map with real OpenStreetMap tiles</li>
|
||
<li>Click aircraft markers to see callsign, altitude, speed, heading</li>
|
||
<li>Map auto-fits to show all tracked aircraft</li>
|
||
<li>Emergency squawk codes highlighted in red</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>
|
||
<li>Monitor for Iridium satellite bursts</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>
|
||
</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/433MHz:</strong> RTL-SDR dongle, rtl_fm, multimon-ng, rtl_433</li>
|
||
<li><strong>Aircraft:</strong> RTL-SDR dongle, dump1090 or rtl_adsb</li>
|
||
<li><strong>Satellite:</strong> Internet connection for Celestrak (optional)</li>
|
||
<li><strong>WiFi:</strong> Monitor-mode capable adapter, aircrack-ng suite</li>
|
||
<li><strong>Bluetooth:</strong> Bluetooth adapter, hcitool/bluetoothctl</li>
|
||
<li>Run as root/sudo for full functionality</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 hcxtools</div>
|
||
<div style="margin-top: 5px;">pip install skyfield flask</div>
|
||
</div>
|
||
</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
|
||
if (missingModes > 0 && !dismissed) {
|
||
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();
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|