mirror of
https://github.com/colonelpanichacks/flock-you.git
synced 2026-06-10 06:03:31 -07:00
3606f1f812
Complete rewrite of standalone Flock-You firmware: - Remove WiFi promiscuous mode, all detection is now BLE-only - Add web dashboard served on AP "flockyou" at 192.168.4.1 - GPS wardriving via phone browser Geolocation API - SPIFFS session persistence with auto-save every 60s - Prior session tab (PREV) survives reboots - KML export for Google Earth (current + prior session) - JSON/CSV export with GPS coordinates - Serial JSON output for Flask live ingestion - Crow call boot sounds with detection/heartbeat alerts - 200 unique device storage with FreeRTOS mutex - Flask app: add KML import endpoint, GPS data handling - Update platformio.ini with AsyncWebServer, ArduinoJson 7, SPIFFS partition - Rewrite README to reflect current functionality
2847 lines
100 KiB
HTML
2847 lines
100 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Flock You - Detection Dashboard</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: linear-gradient(135deg, #2d1b69 0%, #1a1033 50%, #4a1b69 100%);
|
||
color: #e0e0e0;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.header {
|
||
background: rgba(26, 16, 51, 0.95);
|
||
color: #e0e0e0;
|
||
padding: 1rem;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||
border-bottom: 2px solid #8b5cf6;
|
||
}
|
||
|
||
.header-content {
|
||
display: grid;
|
||
grid-template-areas:
|
||
"title controls buttons";
|
||
grid-template-columns: auto 1fr auto;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.header-title {
|
||
grid-area: title;
|
||
text-align: left;
|
||
}
|
||
|
||
.header-controls {
|
||
grid-area: controls;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.header-buttons {
|
||
grid-area: buttons;
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 1.6rem;
|
||
margin: 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.flock-you-title {
|
||
font-family: 'Orbitron', 'Courier New', monospace;
|
||
font-size: 1.4rem;
|
||
font-weight: 700;
|
||
color: #ec4899;
|
||
text-shadow: 0 0 10px rgba(236, 72, 153, 0.5);
|
||
letter-spacing: 2px;
|
||
margin: 0;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.flock-you-title {
|
||
font-size: 1.3rem;
|
||
}
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.device-control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.port-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.refresh-btn {
|
||
background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);
|
||
color: white;
|
||
font-weight: 600;
|
||
border: 1px solid #4b5563;
|
||
padding: 0.3rem 0.5rem;
|
||
font-size: 0.8rem;
|
||
min-width: auto;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
background: linear-gradient(135deg, #9ca3af 0%, #d1d5db 100%);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 8px rgba(107, 114, 128, 0.4);
|
||
}
|
||
|
||
.refresh-btn:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
|
||
|
||
.control-group label {
|
||
font-weight: 600;
|
||
color: #e0e0e0;
|
||
font-size: 0.75rem;
|
||
white-space: nowrap;
|
||
min-width: fit-content;
|
||
}
|
||
|
||
select, button {
|
||
padding: 0.3rem 0.6rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
select {
|
||
background: #2d1b69;
|
||
color: #e0e0e0;
|
||
border: 1px solid #8b5cf6;
|
||
min-width: 100px;
|
||
max-width: 140px;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
select:focus {
|
||
outline: none;
|
||
border-color: #c084fc;
|
||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
button {
|
||
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
|
||
color: white;
|
||
font-weight: 600;
|
||
border: 1px solid #7c3aed;
|
||
}
|
||
|
||
button:hover {
|
||
background: linear-gradient(135deg, #a855f7 0%, #c084fc 100%);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
|
||
}
|
||
|
||
button.danger {
|
||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
|
||
}
|
||
|
||
button.danger:hover {
|
||
background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);
|
||
}
|
||
|
||
.status {
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.serial-terminal-btn {
|
||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||
color: white;
|
||
font-weight: 600;
|
||
border: 1px solid #047857;
|
||
padding: 0.3rem 0.6rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.serial-terminal-btn:hover {
|
||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||
}
|
||
|
||
.oui-search-btn {
|
||
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
|
||
color: white;
|
||
font-weight: 600;
|
||
border: 1px solid #6d28d9;
|
||
padding: 0.3rem 0.6rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.oui-search-btn:hover {
|
||
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
|
||
}
|
||
|
||
.map-btn {
|
||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 100%);
|
||
color: white;
|
||
font-weight: 600;
|
||
border: 1px solid #0e7490;
|
||
padding: 0.3rem 0.6rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.map-btn:hover {
|
||
background: linear-gradient(135deg, #06b6d4 0%, #22d3ee 100%);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(8, 145, 178, 0.4);
|
||
}
|
||
|
||
.serial-terminal-container {
|
||
background: rgba(45, 27, 105, 0.8);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 12px;
|
||
margin-top: 1rem;
|
||
overflow: hidden;
|
||
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.serial-terminal-header {
|
||
background: rgba(45, 27, 105, 0.9);
|
||
padding: 1rem 1.5rem;
|
||
border-bottom: 1px solid #8b5cf6;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.serial-terminal-header h2 {
|
||
color: #c084fc;
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.serial-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.terminal-filter-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.terminal-filter-group label {
|
||
color: #c084fc;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.terminal-filter-select {
|
||
background: rgba(15, 10, 26, 0.95);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 4px;
|
||
color: #e0e0e0;
|
||
padding: 0.3rem 0.6rem;
|
||
font-size: 0.8rem;
|
||
min-width: 100px;
|
||
}
|
||
|
||
.terminal-filter-select:focus {
|
||
outline: none;
|
||
border-color: #c084fc;
|
||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.serial-status {
|
||
color: #a855f7;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.clear-terminal-btn {
|
||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
|
||
color: white;
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.8rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.clear-terminal-btn:hover {
|
||
background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.serial-terminal-content {
|
||
background: rgba(15, 10, 26, 0.95);
|
||
padding: 1rem;
|
||
height: 300px;
|
||
overflow-y: auto;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
color: #e0e0e0;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #8b5cf6 #2d1b69;
|
||
}
|
||
|
||
.serial-terminal-content::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.serial-terminal-content::-webkit-scrollbar-track {
|
||
background: #2d1b69;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.serial-terminal-content::-webkit-scrollbar-thumb {
|
||
background: #8b5cf6;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.serial-terminal-content::-webkit-scrollbar-thumb:hover {
|
||
background: #a855f7;
|
||
}
|
||
|
||
.terminal-placeholder {
|
||
color: #6b7280;
|
||
font-style: italic;
|
||
text-align: center;
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
.serial-line {
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.serial-line.detection {
|
||
color: #c084fc;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.serial-line.error {
|
||
color: #f87171;
|
||
}
|
||
|
||
.serial-line.info {
|
||
color: #60a5fa;
|
||
}
|
||
|
||
.serial-line.success {
|
||
color: #34d399;
|
||
}
|
||
|
||
.serial-line.warning {
|
||
color: #fbbf24;
|
||
}
|
||
|
||
.oui-search-container {
|
||
background: rgba(45, 27, 105, 0.8);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 12px;
|
||
margin-top: 1rem;
|
||
overflow: hidden;
|
||
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.oui-search-header {
|
||
background: rgba(45, 27, 105, 0.9);
|
||
padding: 1rem 1.5rem;
|
||
border-bottom: 1px solid #8b5cf6;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.oui-search-header h2 {
|
||
color: #c084fc;
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.oui-search-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.oui-status {
|
||
color: #a855f7;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.view-all-btn {
|
||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||
color: white;
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.8rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.view-all-btn:hover {
|
||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.refresh-db-btn {
|
||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||
color: white;
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.8rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.refresh-db-btn:hover {
|
||
background: linear-gradient(135deg, #fbbf24 0%, #fcd34d 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.clear-oui-btn {
|
||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
|
||
color: white;
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.8rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.clear-oui-btn:hover {
|
||
background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.oui-search-content {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.search-input-group {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.oui-search-input {
|
||
flex: 1;
|
||
background: rgba(15, 10, 26, 0.95);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
padding: 0.5rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.oui-search-input:focus {
|
||
outline: none;
|
||
border-color: #c084fc;
|
||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||
background: rgba(15, 10, 26, 0.95);
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.oui-search-input::placeholder {
|
||
color: #6b7280;
|
||
}
|
||
|
||
.search-btn {
|
||
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
|
||
color: white;
|
||
font-size: 0.9rem;
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.search-btn:hover {
|
||
background: linear-gradient(135deg, #a855f7 0%, #c084fc 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.search-results {
|
||
background: rgba(15, 10, 26, 0.95);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 6px;
|
||
padding: 1rem;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #8b5cf6 #2d1b69;
|
||
}
|
||
|
||
.oui-results-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 0.3rem;
|
||
max-height: 350px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.oui-result-item {
|
||
background: rgba(45, 27, 105, 0.5);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 4px;
|
||
padding: 0.3rem;
|
||
margin-bottom: 0;
|
||
font-size: 0.7rem;
|
||
min-height: 40px;
|
||
}
|
||
|
||
.oui-result-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.oui-mac {
|
||
color: #c084fc;
|
||
font-weight: 600;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.oui-manufacturer {
|
||
color: #e0e0e0;
|
||
margin-top: 0.25rem;
|
||
font-size: 0.7rem;
|
||
line-height: 1.2;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.search-results::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.search-results::-webkit-scrollbar-track {
|
||
background: #2d1b69;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.search-results::-webkit-scrollbar-thumb {
|
||
background: #8b5cf6;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.search-results::-webkit-scrollbar-thumb:hover {
|
||
background: #a855f7;
|
||
}
|
||
|
||
.search-placeholder {
|
||
color: #6b7280;
|
||
font-style: italic;
|
||
text-align: center;
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
.oui-result-item {
|
||
background: rgba(45, 27, 105, 0.5);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 6px;
|
||
padding: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.oui-result-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.oui-mac {
|
||
color: #c084fc;
|
||
font-weight: 600;
|
||
font-family: 'Courier New', monospace;
|
||
}
|
||
|
||
.oui-manufacturer {
|
||
color: #e0e0e0;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.alias-display {
|
||
cursor: pointer;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
min-width: 100px;
|
||
background: #1e40af;
|
||
border: 1px solid #3b82f6;
|
||
color: #93c5fd;
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.alias-display:hover {
|
||
background: #2563eb;
|
||
border-color: #60a5fa;
|
||
color: white;
|
||
}
|
||
|
||
.alias-display em {
|
||
color: #6b7280;
|
||
font-style: italic;
|
||
}
|
||
|
||
.alias-input {
|
||
background: rgba(45, 27, 105, 0.8);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 4px;
|
||
color: #e0e0e0;
|
||
padding: 2px 6px;
|
||
font-size: 0.9rem;
|
||
width: 120px;
|
||
}
|
||
|
||
.alias-input:focus {
|
||
outline: none;
|
||
border-color: #c084fc;
|
||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.status-indicator {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: #dc2626;
|
||
animation: pulse 2s infinite;
|
||
box-shadow: 0 0 8px rgba(220, 38, 38, 0.6);
|
||
}
|
||
|
||
.status-indicator.active {
|
||
background: #10b981;
|
||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
|
||
}
|
||
|
||
.device-info {
|
||
font-size: 0.8rem;
|
||
color: #d1d5db;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
|
||
|
||
@keyframes pulse {
|
||
0% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
100% { opacity: 1; }
|
||
}
|
||
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.main-content {
|
||
padding: 1rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 1rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.stat-card {
|
||
background: rgba(45, 27, 105, 0.8);
|
||
padding: 1.5rem;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
text-align: center;
|
||
border: 1px solid #8b5cf6;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.stat-number {
|
||
font-size: 2rem;
|
||
font-weight: bold;
|
||
color: #c084fc;
|
||
}
|
||
|
||
.stat-label {
|
||
color: #d1d5db;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.stat-hint {
|
||
color: #9ca3af;
|
||
font-size: 0.7rem;
|
||
margin-top: 0.25rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
.detections-container {
|
||
background: rgba(45, 27, 105, 0.8);
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
overflow: hidden;
|
||
border: 1px solid #8b5cf6;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.detections-header {
|
||
background: rgba(26, 16, 51, 0.9);
|
||
padding: 1rem;
|
||
border-bottom: 1px solid #8b5cf6;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.detections-header h2 {
|
||
color: #c084fc;
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.detections-list {
|
||
max-height: 600px;
|
||
overflow-y: auto;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #8b5cf6 #2d1b69;
|
||
}
|
||
|
||
.detections-list::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.detections-list::-webkit-scrollbar-track {
|
||
background: #2d1b69;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.detections-list::-webkit-scrollbar-thumb {
|
||
background: #8b5cf6;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.detections-list::-webkit-scrollbar-thumb:hover {
|
||
background: #a855f7;
|
||
}
|
||
|
||
.detection-item {
|
||
background: #1e3a8a;
|
||
border: 1px solid #3b82f6;
|
||
border-radius: 6px;
|
||
padding: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||
min-height: 5rem;
|
||
}
|
||
|
||
.detection-item:hover {
|
||
background: #1e40af;
|
||
border-color: #60a5fa;
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.detection-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.detection-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.gps-link {
|
||
color: #93c5fd;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
padding: 0.1rem 0.3rem;
|
||
border-radius: 3px;
|
||
background: rgba(147, 197, 253, 0.1);
|
||
border: 1px solid #3b82f6;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.gps-link:hover {
|
||
background: #3b82f6;
|
||
color: white;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.detection-type-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.detection-type {
|
||
background: #2563eb;
|
||
color: white;
|
||
padding: 0.25rem 0.6rem;
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.gps-tag {
|
||
background: #22c55e;
|
||
color: white;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
.detection-count {
|
||
background: #059669;
|
||
color: white;
|
||
padding: 0.2rem 0.5rem;
|
||
border-radius: 4px;
|
||
font-size: 0.85rem;
|
||
font-weight: 700;
|
||
min-width: 2.5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.detection-time {
|
||
color: #93c5fd;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detection-details {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
font-size: 0.9rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.detail-item {
|
||
display: flex;
|
||
align-items: center;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.detail-label {
|
||
font-weight: 700;
|
||
color: #93c5fd;
|
||
font-size: 0.9rem;
|
||
margin-right: 0.5rem;
|
||
}
|
||
|
||
.detail-value {
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.detection-timing {
|
||
grid-column: 1 / -1;
|
||
background: rgba(16, 185, 129, 0.1);
|
||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||
border-radius: 4px;
|
||
padding: 0.2rem;
|
||
margin-top: 0.15rem;
|
||
font-size: 0.65rem;
|
||
}
|
||
|
||
|
||
|
||
.no-detections {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: #d1d5db;
|
||
}
|
||
|
||
.export-buttons {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-top: 1rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.detection-search-group {
|
||
margin-right: 0.5rem;
|
||
}
|
||
|
||
.detection-search-input {
|
||
background: rgba(15, 10, 26, 0.95);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
padding: 0.4rem 0.8rem;
|
||
font-size: 0.8rem;
|
||
width: 200px;
|
||
}
|
||
|
||
.detection-search-input:focus {
|
||
outline: none;
|
||
border-color: #c084fc;
|
||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||
background: rgba(15, 10, 26, 0.95);
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.detection-search-input::placeholder {
|
||
color: #6b7280;
|
||
}
|
||
|
||
.export-btn {
|
||
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
|
||
border: 1px solid #7c3aed;
|
||
}
|
||
|
||
.export-btn:hover {
|
||
background: linear-gradient(135deg, #a855f7 0%, #c084fc 100%);
|
||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
|
||
}
|
||
|
||
.clear-btn {
|
||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||
border: 1px solid #db2777;
|
||
}
|
||
|
||
.clear-btn:hover {
|
||
background: linear-gradient(135deg, #f472b6 0%, #f9a8d4 100%);
|
||
box-shadow: 0 4px 12px rgba(236, 72, 153, 0.4);
|
||
}
|
||
|
||
.export-dropdown {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
|
||
.export-dropdown-content {
|
||
display: none;
|
||
position: absolute;
|
||
right: 0;
|
||
background: rgba(45, 27, 105, 0.95);
|
||
min-width: 160px;
|
||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 6px;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.export-dropdown-content a {
|
||
color: #e0e0e0;
|
||
padding: 8px 12px;
|
||
text-decoration: none;
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
transition: background-color 0.3s;
|
||
}
|
||
|
||
.export-dropdown-content a:hover {
|
||
background: rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.dropdown-toggle {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.map-container {
|
||
background: rgba(45, 27, 105, 0.8);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 12px;
|
||
margin-top: 1rem;
|
||
overflow: hidden;
|
||
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.map-header {
|
||
background: rgba(45, 27, 105, 0.9);
|
||
padding: 1rem 1.5rem;
|
||
border-bottom: 1px solid #8b5cf6;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.map-header h2 {
|
||
color: #c084fc;
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.map-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.map-layer-group,
|
||
.map-filter-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.map-layer-group label,
|
||
.map-filter-group label {
|
||
color: #c084fc;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.map-layer-select,
|
||
.map-filter-select {
|
||
background: rgba(15, 10, 26, 0.95);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 4px;
|
||
color: #e0e0e0;
|
||
padding: 0.3rem 0.6rem;
|
||
font-size: 0.8rem;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.map-layer-select:focus,
|
||
.map-filter-select:focus {
|
||
outline: none;
|
||
border-color: #c084fc;
|
||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||
}
|
||
|
||
.map-status {
|
||
color: #a855f7;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.clear-map-btn {
|
||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
|
||
color: white;
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.8rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.clear-map-btn:hover {
|
||
background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.map-content {
|
||
padding: 0;
|
||
}
|
||
|
||
.leaflet-popup-content {
|
||
color: #333;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.leaflet-popup-content h3 {
|
||
margin: 0 0 0.5rem 0;
|
||
color: #ec4899;
|
||
}
|
||
|
||
.map-legend {
|
||
background: rgba(15, 10, 26, 0.9);
|
||
border: 1px solid #8b5cf6;
|
||
border-radius: 6px;
|
||
padding: 0.5rem;
|
||
margin-top: 0.5rem;
|
||
font-size: 0.8rem;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.legend-title {
|
||
color: #c084fc;
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.legend-items {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
font-size: 0.75rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.legend-marker {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
border: 2px solid;
|
||
}
|
||
|
||
.wifi-session { background: #ef4444; border-color: #22c55e; }
|
||
.wifi-cumulative { background: #ef4444; border-color: #f59e0b; }
|
||
.ble-session { background: #3b82f6; border-color: #22c55e; }
|
||
.ble-cumulative { background: #3b82f6; border-color: #f59e0b; }
|
||
|
||
@media (max-width: 1200px) {
|
||
.header-content {
|
||
grid-template-areas:
|
||
"title title"
|
||
"controls buttons";
|
||
grid-template-columns: 1fr auto;
|
||
}
|
||
|
||
.header-title {
|
||
text-align: center;
|
||
}
|
||
|
||
.header-controls {
|
||
justify-content: center;
|
||
}
|
||
|
||
.header-buttons {
|
||
justify-content: center;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.header-content {
|
||
grid-template-areas:
|
||
"title title"
|
||
"controls controls"
|
||
"buttons buttons";
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.header-title {
|
||
text-align: center;
|
||
}
|
||
|
||
.header-controls {
|
||
justify-content: center;
|
||
}
|
||
|
||
.header-buttons {
|
||
justify-content: center;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.header {
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
.flock-you-title {
|
||
font-size: 1.1rem;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.header-controls {
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.controls {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.75rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.device-control-group,
|
||
.control-group {
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.action-buttons {
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.serial-terminal-btn,
|
||
.oui-search-btn,
|
||
.map-btn {
|
||
width: 100%;
|
||
text-align: center;
|
||
}
|
||
|
||
.detection-details {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
select {
|
||
min-width: 80px;
|
||
max-width: none;
|
||
flex: 1;
|
||
}
|
||
|
||
.port-controls {
|
||
flex: 1;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.refresh-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
font-size: 0.7rem;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<div class="header-content">
|
||
<div class="header-title">
|
||
<h1 class="flock-you-title">FLOCK-YOU</h1>
|
||
</div>
|
||
|
||
<div class="header-controls">
|
||
<div class="controls">
|
||
<div class="device-control-group">
|
||
<div class="status-indicator" id="flockStatus"></div>
|
||
<label>Sniffer:</label>
|
||
<div class="port-controls">
|
||
<select id="flockDeviceSelect">
|
||
<option value="">Select Port</option>
|
||
</select>
|
||
<button id="refreshFlockPortsBtn" class="refresh-btn" title="Refresh ports" onclick="loadFlockPorts()">↻</button>
|
||
</div>
|
||
<button id="connectFlockBtn">Connect</button>
|
||
<button id="disconnectFlockBtn" class="danger" style="display: none;">Disconnect</button>
|
||
</div>
|
||
|
||
<div class="device-control-group">
|
||
<div class="status-indicator" id="gpsStatus"></div>
|
||
<label>GPS:</label>
|
||
<div class="port-controls">
|
||
<select id="gpsPortSelect">
|
||
<option value="">Select Port</option>
|
||
</select>
|
||
<button id="refreshGpsPortsBtn" class="refresh-btn" title="Refresh ports" onclick="loadGpsPorts()">↻</button>
|
||
</div>
|
||
<button id="connectGpsBtn">Connect</button>
|
||
<button id="disconnectGpsBtn" class="danger" style="display: none;">Disconnect</button>
|
||
</div>
|
||
|
||
<div class="control-group">
|
||
<label>Filter:</label>
|
||
<select id="filterSelect">
|
||
<option value="all">All Detections</option>
|
||
<option value="probe_request">WiFi Probe Requests</option>
|
||
<option value="beacon">WiFi Beacons</option>
|
||
<option value="mac_prefix">MAC Address</option>
|
||
<option value="device_name">BLE Device Name</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="header-buttons">
|
||
<div class="action-buttons">
|
||
<button id="serial-terminal-btn" class="serial-terminal-btn" onclick="toggleSerialTerminal()">Terminal</button>
|
||
<button id="oui-search-btn" class="oui-search-btn" onclick="toggleOuiSearch()">OUI</button>
|
||
<button id="map-btn" class="map-btn" onclick="toggleMap()">Map</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-content">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-number" id="totalDetections">0</div>
|
||
<div class="stat-label">Session Detections</div>
|
||
<div class="stat-hint">(Hover for cumulative)</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-number" id="wifiDetections">0</div>
|
||
<div class="stat-label">WiFi Detections</div>
|
||
<div class="stat-hint">(Hover for cumulative)</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-number" id="bleDetections">0</div>
|
||
<div class="stat-label">BLE Detections</div>
|
||
<div class="stat-hint">(Hover for cumulative)</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-number" id="gpsDetections">0</div>
|
||
<div class="stat-label">GPS Tagged</div>
|
||
<div class="stat-hint">(Hover for cumulative)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detections-container">
|
||
<div class="detections-header">
|
||
<h2>Detections</h2>
|
||
<div class="export-buttons">
|
||
<div class="detection-search-group">
|
||
<input type="text" id="detectionSearchInput" class="detection-search-input" placeholder="Search detections..." onkeyup="searchDetections()">
|
||
</div>
|
||
<div class="export-dropdown">
|
||
<button class="export-btn dropdown-toggle" onclick="toggleExportDropdown()">Export ▼</button>
|
||
<div class="export-dropdown-content" id="exportDropdown">
|
||
<a href="#" onclick="exportCSV('session')">Session CSV</a>
|
||
<a href="#" onclick="exportKML('session')">Session KML</a>
|
||
<a href="#" onclick="exportCSV('cumulative')">Cumulative CSV</a>
|
||
<a href="#" onclick="exportKML('cumulative')">Cumulative KML</a>
|
||
</div>
|
||
</div>
|
||
<div class="export-dropdown">
|
||
<button class="export-btn dropdown-toggle" style="background:linear-gradient(135deg,#059669 0%,#10b981 100%);border-color:#047857" onclick="toggleImportDropdown()">Import ▼</button>
|
||
<div class="export-dropdown-content" id="importDropdown">
|
||
<a href="#" onclick="importFile('json')">Import JSON (from ESP32)</a>
|
||
<a href="#" onclick="importFile('csv')">Import CSV (from ESP32)</a>
|
||
<a href="#" onclick="importFile('kml')">Import KML (from ESP32)</a>
|
||
</div>
|
||
</div>
|
||
<input type="file" id="importFileInput" accept=".json,.csv,.kml" style="display:none" onchange="handleImportFile()">
|
||
<button class="clear-btn" onclick="clearDetections()">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="detections-list" id="detectionsList">
|
||
<div class="no-detections">
|
||
<h3>No detections yet</h3>
|
||
<p>Start scanning with your Flock You device to see detections here</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="serial-terminal-container" id="serialTerminalContainer" style="display: none;">
|
||
<div class="serial-terminal-header">
|
||
<h2>Serial Terminal</h2>
|
||
<div class="serial-controls">
|
||
<div class="terminal-filter-group">
|
||
<label for="terminalFilter">Filter:</label>
|
||
<select id="terminalFilter" class="terminal-filter-select" onchange="filterTerminalData()">
|
||
<option value="all">All Data</option>
|
||
<option value="sniffer">Sniffer Only</option>
|
||
<option value="gps">GPS Only</option>
|
||
</select>
|
||
</div>
|
||
<span class="serial-status">Status: <span id="serialConnectionStatus">Disconnected</span></span>
|
||
<button class="clear-terminal-btn" onclick="clearSerialTerminal()">Clear Terminal</button>
|
||
</div>
|
||
</div>
|
||
<div class="serial-terminal-content" id="serialTerminalOutput">
|
||
<div class="terminal-placeholder">Connect to a Sniffer device to see serial output...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="oui-search-container" id="ouiSearchContainer" style="display: none;">
|
||
<div class="oui-search-header">
|
||
<h2>OUI Database Search</h2>
|
||
<div class="oui-search-controls">
|
||
<span class="oui-status">Search IEEE OUI Database</span>
|
||
<button class="view-all-btn" onclick="viewAllOui()">View All</button>
|
||
<button class="refresh-db-btn" onclick="refreshOuiDatabase()">Refresh DB</button>
|
||
<button class="clear-oui-btn" onclick="clearOuiResults()">Clear Results</button>
|
||
</div>
|
||
</div>
|
||
<div class="oui-search-content">
|
||
<div class="search-input-group">
|
||
<input type="text" id="ouiSearchInput" class="oui-search-input" placeholder="Enter MAC address (e.g., 00:11:22) or manufacturer name..." onkeypress="handleOuiSearch(event)">
|
||
<button class="search-btn" onclick="searchOui()">Search</button>
|
||
</div>
|
||
<div class="search-results" id="ouiSearchResults">
|
||
<div class="search-placeholder">Enter a MAC address or manufacturer name to search...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="map-container" id="mapContainer" style="display: none;">
|
||
<div class="map-header">
|
||
<h2>Detection Map</h2>
|
||
<div class="map-controls">
|
||
<div class="map-layer-group">
|
||
<label for="mapLayer">Layer:</label>
|
||
<select id="mapLayer" class="map-layer-select" onchange="changeMapLayer()">
|
||
<option value="osm">OpenStreetMap</option>
|
||
<option value="satellite">Satellite</option>
|
||
<option value="topo">Topographic</option>
|
||
</select>
|
||
</div>
|
||
<div class="map-filter-group">
|
||
<label for="mapFilter">Data:</label>
|
||
<select id="mapFilter" class="map-filter-select" onchange="filterMapData()">
|
||
<option value="session">Session Only</option>
|
||
<option value="cumulative">Cumulative Only</option>
|
||
<option value="both">Session + Cumulative</option>
|
||
</select>
|
||
</div>
|
||
<span class="map-status">Showing: <span id="mapDetectionCount">0</span> detections</span>
|
||
<button class="clear-map-btn" onclick="clearMapMarkers()">Clear Markers</button>
|
||
</div>
|
||
</div>
|
||
<div class="map-content" id="mapContent">
|
||
<div id="map" style="height: 400px; width: 100%;"></div>
|
||
<div class="map-legend">
|
||
<h4 class="legend-title">Legend:</h4>
|
||
<div class="legend-items">
|
||
<div class="legend-item">
|
||
<div class="legend-marker wifi-session"></div>
|
||
<span>WiFi (Session)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker wifi-cumulative"></div>
|
||
<span>WiFi (Cumulative)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker ble-session"></div>
|
||
<span>BLE (Session)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker ble-cumulative"></div>
|
||
<span>BLE (Cumulative)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<script>
|
||
const socket = io({
|
||
transports: ['websocket', 'polling'],
|
||
reconnection: true,
|
||
reconnectionAttempts: 5,
|
||
reconnectionDelay: 1000,
|
||
reconnectionDelayMax: 5000
|
||
});
|
||
let detections = [];
|
||
let cumulativeDetections = [];
|
||
let gpsConnected = false;
|
||
const max_reconnect_attempts = 5;
|
||
let userInteractingWithPorts = false; // Flag to prevent auto-refresh interference
|
||
let terminalFilter = 'all';
|
||
let allTerminalData = [];
|
||
let map = null;
|
||
let mapMarkers = [];
|
||
let mapLayers = {};
|
||
let mapFilter = 'session';
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadDetections();
|
||
loadCumulativeDetections();
|
||
loadFlockPorts();
|
||
loadGpsPorts();
|
||
setupEventListeners();
|
||
loadStatus();
|
||
loadSettings();
|
||
|
||
// Periodic status refresh every 5 seconds
|
||
setInterval(loadStatus, 5000);
|
||
|
||
// Periodic port refresh every 30 seconds (reduced from 10 seconds)
|
||
setInterval(function() {
|
||
// Only refresh if user is not actively interacting with ports
|
||
if (!userInteractingWithPorts) {
|
||
loadFlockPorts();
|
||
loadGpsPorts();
|
||
}
|
||
}, 30000);
|
||
|
||
// Connection health check every 30 seconds
|
||
setInterval(function() {
|
||
if (!socket.connected) {
|
||
console.log('Socket disconnected, attempting to reconnect...');
|
||
socket.connect();
|
||
}
|
||
}, 30000);
|
||
});
|
||
|
||
function setupEventListeners() {
|
||
document.getElementById('filterSelect').addEventListener('change', function() {
|
||
filterDetections(this.value);
|
||
saveSettings();
|
||
});
|
||
|
||
document.getElementById('flockDeviceSelect').addEventListener('change', function() {
|
||
saveSettings();
|
||
});
|
||
|
||
document.getElementById('gpsPortSelect').addEventListener('change', function() {
|
||
saveSettings();
|
||
});
|
||
|
||
document.getElementById('connectFlockBtn').addEventListener('click', connectFlock);
|
||
document.getElementById('disconnectFlockBtn').addEventListener('click', disconnectFlock);
|
||
document.getElementById('connectGpsBtn').addEventListener('click', connectGps);
|
||
document.getElementById('disconnectGpsBtn').addEventListener('click', disconnectGps);
|
||
|
||
// Add event listeners for port dropdowns to prevent auto-refresh interference
|
||
const flockSelect = document.getElementById('flockDeviceSelect');
|
||
const gpsSelect = document.getElementById('gpsPortSelect');
|
||
|
||
flockSelect.addEventListener('focus', () => { userInteractingWithPorts = true; });
|
||
flockSelect.addEventListener('blur', () => {
|
||
setTimeout(() => { userInteractingWithPorts = false; }, 1000);
|
||
});
|
||
flockSelect.addEventListener('change', () => { userInteractingWithPorts = true; });
|
||
|
||
gpsSelect.addEventListener('focus', () => { userInteractingWithPorts = true; });
|
||
gpsSelect.addEventListener('blur', () => {
|
||
setTimeout(() => { userInteractingWithPorts = false; }, 1000);
|
||
});
|
||
gpsSelect.addEventListener('change', () => { userInteractingWithPorts = true; });
|
||
}
|
||
|
||
function loadDetections() {
|
||
fetch('/api/detections')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Loaded detections:', data.length);
|
||
detections = data;
|
||
updateStats();
|
||
renderDetections();
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading detections:', error);
|
||
});
|
||
}
|
||
|
||
function loadSettings() {
|
||
fetch('/api/settings')
|
||
.then(response => response.json())
|
||
.then(settings => {
|
||
console.log('Loaded settings:', settings);
|
||
|
||
// Apply settings to UI
|
||
if (settings.gps_port) {
|
||
document.getElementById('gpsPortSelect').value = settings.gps_port;
|
||
}
|
||
if (settings.flock_port) {
|
||
document.getElementById('flockDeviceSelect').value = settings.flock_port;
|
||
}
|
||
if (settings.filter) {
|
||
document.getElementById('filterSelect').value = settings.filter;
|
||
filterDetections(settings.filter);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading settings:', error);
|
||
});
|
||
}
|
||
|
||
function saveSettings() {
|
||
const settings = {
|
||
gps_port: document.getElementById('gpsPortSelect').value,
|
||
flock_port: document.getElementById('flockDeviceSelect').value,
|
||
filter: document.getElementById('filterSelect').value
|
||
};
|
||
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(settings)
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Settings saved:', data);
|
||
})
|
||
.catch(error => {
|
||
console.error('Error saving settings:', error);
|
||
});
|
||
}
|
||
|
||
function loadGpsPorts() {
|
||
const select = document.getElementById('gpsPortSelect');
|
||
const refreshBtn = document.getElementById('refreshGpsPortsBtn');
|
||
const currentSelection = select.value; // Preserve current selection
|
||
|
||
// Show loading state
|
||
refreshBtn.textContent = '⟳';
|
||
refreshBtn.style.animation = 'spin 1s linear infinite';
|
||
|
||
// Add a subtle visual indicator if this is an auto-refresh
|
||
if (!userInteractingWithPorts) {
|
||
refreshBtn.title = 'Auto-refreshing ports...';
|
||
}
|
||
|
||
fetch('/api/gps/ports')
|
||
.then(response => response.json())
|
||
.then(ports => {
|
||
console.log('GPS ports loaded:', ports.length);
|
||
|
||
select.innerHTML = '<option value="">Select GPS Port</option>';
|
||
ports.forEach(port => {
|
||
const option = document.createElement('option');
|
||
option.value = port.device;
|
||
option.textContent = `${port.device} - ${port.description}`;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
// Restore selection if it still exists in the new list
|
||
if (currentSelection && ports.some(p => p.device === currentSelection)) {
|
||
select.value = currentSelection;
|
||
}
|
||
|
||
// Reset refresh button
|
||
refreshBtn.textContent = '↻';
|
||
refreshBtn.style.animation = '';
|
||
refreshBtn.title = 'Refresh ports';
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading GPS ports:', error);
|
||
// Reset refresh button on error
|
||
refreshBtn.textContent = '↻';
|
||
refreshBtn.style.animation = '';
|
||
refreshBtn.title = 'Refresh ports';
|
||
});
|
||
}
|
||
|
||
function loadFlockPorts() {
|
||
const select = document.getElementById('flockDeviceSelect');
|
||
const refreshBtn = document.getElementById('refreshFlockPortsBtn');
|
||
const currentSelection = select.value; // Preserve current selection
|
||
|
||
// Show loading state
|
||
refreshBtn.textContent = '⟳';
|
||
refreshBtn.style.animation = 'spin 1s linear infinite';
|
||
|
||
// Add a subtle visual indicator if this is an auto-refresh
|
||
if (!userInteractingWithPorts) {
|
||
refreshBtn.title = 'Auto-refreshing ports...';
|
||
}
|
||
|
||
fetch('/api/flock/ports')
|
||
.then(response => response.json())
|
||
.then(ports => {
|
||
console.log('Flock ports loaded:', ports.length);
|
||
|
||
select.innerHTML = '<option value="">Select Flock You Port</option>';
|
||
ports.forEach(port => {
|
||
const option = document.createElement('option');
|
||
option.value = port.device;
|
||
option.textContent = `${port.device} - ${port.description}`;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
// Restore selection if it still exists in the new list
|
||
if (currentSelection && ports.some(p => p.device === currentSelection)) {
|
||
select.value = currentSelection;
|
||
}
|
||
|
||
// Reset refresh button
|
||
refreshBtn.textContent = '↻';
|
||
refreshBtn.style.animation = '';
|
||
refreshBtn.title = 'Refresh ports';
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading Flock ports:', error);
|
||
// Reset refresh button on error
|
||
refreshBtn.textContent = '↻';
|
||
refreshBtn.style.animation = '';
|
||
refreshBtn.title = 'Refresh ports';
|
||
});
|
||
}
|
||
|
||
function connectFlock() {
|
||
const port = document.getElementById('flockDeviceSelect').value;
|
||
if (!port) {
|
||
alert('Please select a Sniffer port');
|
||
return;
|
||
}
|
||
|
||
console.log('Connecting to Flock device on port:', port);
|
||
|
||
fetch('/api/flock/connect', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ port: port })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Flock connection response:', data);
|
||
if (data.status === 'success') {
|
||
updateFlockStatus(true);
|
||
document.getElementById('connectFlockBtn').style.display = 'none';
|
||
document.getElementById('disconnectFlockBtn').style.display = 'inline-block';
|
||
} else {
|
||
alert('Flock You connection failed: ' + data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Flock connection error:', error);
|
||
alert('Connection failed: ' + error.message);
|
||
});
|
||
}
|
||
|
||
function disconnectFlock() {
|
||
console.log('Disconnecting Flock device');
|
||
|
||
fetch('/api/flock/disconnect', {
|
||
method: 'POST'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Flock disconnect response:', data);
|
||
if (data.status === 'success') {
|
||
updateFlockStatus(false);
|
||
document.getElementById('connectFlockBtn').style.display = 'inline-block';
|
||
document.getElementById('disconnectFlockBtn').style.display = 'none';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Flock disconnect error:', error);
|
||
});
|
||
}
|
||
|
||
function connectGps() {
|
||
const port = document.getElementById('gpsPortSelect').value;
|
||
if (!port) {
|
||
alert('Please select a GPS port');
|
||
return;
|
||
}
|
||
|
||
console.log('Connecting to GPS device on port:', port);
|
||
|
||
fetch('/api/gps/connect', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ port: port })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('GPS connection response:', data);
|
||
if (data.status === 'success') {
|
||
gpsConnected = true;
|
||
updateGpsStatus(true);
|
||
document.getElementById('connectGpsBtn').style.display = 'none';
|
||
document.getElementById('disconnectGpsBtn').style.display = 'inline-block';
|
||
} else {
|
||
alert('GPS connection failed: ' + data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('GPS connection error:', error);
|
||
alert('Connection failed: ' + error.message);
|
||
});
|
||
}
|
||
|
||
function disconnectGps() {
|
||
console.log('Disconnecting GPS device');
|
||
|
||
fetch('/api/gps/disconnect', {
|
||
method: 'POST'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('GPS disconnect response:', data);
|
||
if (data.status === 'success') {
|
||
gpsConnected = false;
|
||
updateGpsStatus(false);
|
||
document.getElementById('connectGpsBtn').style.display = 'inline-block';
|
||
document.getElementById('disconnectGpsBtn').style.display = 'none';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('GPS disconnect error:', error);
|
||
});
|
||
}
|
||
|
||
function updateFlockStatus(connected) {
|
||
const indicator = document.getElementById('flockStatus');
|
||
|
||
if (connected) {
|
||
indicator.classList.add('active');
|
||
} else {
|
||
indicator.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
function updateGpsStatus(connected) {
|
||
const indicator = document.getElementById('gpsStatus');
|
||
|
||
if (connected) {
|
||
indicator.classList.add('active');
|
||
} else {
|
||
indicator.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
function loadStatus() {
|
||
fetch('/api/status')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Status loaded:', data);
|
||
updateFlockStatus(data.flock_connected);
|
||
updateGpsStatus(data.gps_connected);
|
||
|
||
if (data.flock_connected) {
|
||
document.getElementById('connectFlockBtn').style.display = 'none';
|
||
document.getElementById('disconnectFlockBtn').style.display = 'inline-block';
|
||
}
|
||
|
||
if (data.gps_connected) {
|
||
document.getElementById('connectGpsBtn').style.display = 'none';
|
||
document.getElementById('disconnectGpsBtn').style.display = 'inline-block';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading status:', error);
|
||
});
|
||
}
|
||
|
||
function filterDetections(filter) {
|
||
if (filter === 'all') {
|
||
renderDetections(detections);
|
||
} else {
|
||
const filtered = detections.filter(d => d.detection_method === filter);
|
||
renderDetections(filtered);
|
||
}
|
||
}
|
||
|
||
function searchDetections() {
|
||
const searchQuery = document.getElementById('detectionSearchInput').value.toLowerCase().trim();
|
||
|
||
if (!searchQuery) {
|
||
// If search is empty, show all detections
|
||
renderDetections(detections);
|
||
return;
|
||
}
|
||
|
||
// Search through all detection fields
|
||
const filtered = detections.filter(detection => {
|
||
return (
|
||
detection.mac_address.toLowerCase().includes(searchQuery) ||
|
||
(detection.manufacturer && detection.manufacturer.toLowerCase().includes(searchQuery)) ||
|
||
(detection.alias && detection.alias.toLowerCase().includes(searchQuery)) ||
|
||
detection.detection_method.toLowerCase().includes(searchQuery) ||
|
||
detection.protocol.toLowerCase().includes(searchQuery) ||
|
||
detection.detection_time.toLowerCase().includes(searchQuery) ||
|
||
(detection.ssid && detection.ssid.toLowerCase().includes(searchQuery)) ||
|
||
(detection.device_name && detection.device_name.toLowerCase().includes(searchQuery))
|
||
);
|
||
});
|
||
|
||
renderDetections(filtered);
|
||
}
|
||
|
||
function updateStats() {
|
||
const total = detections.length;
|
||
const wifi = detections.filter(d => d.protocol === 'wifi').length;
|
||
const ble = detections.filter(d => d.protocol === 'bluetooth_le' || d.protocol === 'bluetooth_classic').length;
|
||
const gps = detections.filter(d => d.gps).length;
|
||
|
||
document.getElementById('totalDetections').textContent = total;
|
||
document.getElementById('wifiDetections').textContent = wifi;
|
||
document.getElementById('bleDetections').textContent = ble;
|
||
document.getElementById('gpsDetections').textContent = gps;
|
||
|
||
// Also update with cumulative stats in tooltip or additional display
|
||
fetch('/api/stats')
|
||
.then(response => response.json())
|
||
.then(stats => {
|
||
document.getElementById('totalDetections').title = `Session: ${stats.session.total} | Cumulative: ${stats.cumulative.total}`;
|
||
document.getElementById('wifiDetections').title = `Session: ${stats.session.wifi} | Cumulative: ${stats.cumulative.wifi}`;
|
||
document.getElementById('bleDetections').title = `Session: ${stats.session.ble} | Cumulative: ${stats.cumulative.ble}`;
|
||
document.getElementById('gpsDetections').title = `Session: ${stats.session.gps} | Cumulative: ${stats.cumulative.gps}`;
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading stats:', error);
|
||
});
|
||
}
|
||
|
||
function renderDetections(detectionsToRender = detections) {
|
||
const container = document.getElementById('detectionsList');
|
||
|
||
if (detectionsToRender.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="no-detections">
|
||
<h3>No detections found</h3>
|
||
<p>Try changing the filter or wait for new detections</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = detectionsToRender.map(detection => {
|
||
// Get detection count and timing info
|
||
const count = detection.detection_count || 1;
|
||
const lastSeen = detection.last_seen ? new Date(detection.last_seen).toLocaleTimeString() : 'Unknown';
|
||
|
||
// Use last known values for signal data
|
||
const rssi = detection.last_rssi !== undefined ? detection.last_rssi : detection.rssi;
|
||
const channel = detection.last_channel || detection.channel;
|
||
const ssid = detection.last_ssid || detection.ssid;
|
||
const deviceName = detection.last_device_name || detection.device_name;
|
||
|
||
// Build essential fields in a compact layout
|
||
const essentialFields = [];
|
||
if (detection.protocol) essentialFields.push(['Protocol', detection.protocol]);
|
||
if (detection.mac_address) essentialFields.push(['MAC', detection.mac_address]);
|
||
if (rssi !== undefined) essentialFields.push(['RSSI', `${rssi} dBm`]);
|
||
if (channel) essentialFields.push(['Channel', channel]);
|
||
if (ssid) essentialFields.push(['SSID', ssid]);
|
||
if (deviceName) essentialFields.push(['Device', deviceName]);
|
||
if (detection.manufacturer) essentialFields.push(['Manufacturer', detection.manufacturer]);
|
||
|
||
// Build the details HTML
|
||
const detailsHtml = essentialFields.map(([label, value]) => `
|
||
<div class="detail-item">
|
||
<span class="detail-label">${label}:</span>
|
||
<span class="detail-value">${value}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Build GPS link if available (header only)
|
||
let gpsLink = '';
|
||
if (detection.gps && detection.gps.latitude !== undefined && detection.gps.longitude !== undefined) {
|
||
const lat = detection.gps.latitude;
|
||
const lon = detection.gps.longitude;
|
||
const osmUrl = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=18`;
|
||
gpsLink = `<a href="${osmUrl}" target="_blank" class="gps-link">${lat.toFixed(4)}, ${lon.toFixed(4)}</a>`;
|
||
}
|
||
|
||
return `
|
||
<div class="detection-item">
|
||
<div class="detection-header">
|
||
<div class="detection-header-left">
|
||
<div class="detection-type-badge">
|
||
<span class="detection-type">${detection.detection_method ? detection.detection_method.toUpperCase() : 'UNKNOWN'}</span>
|
||
<span class="detection-count">${count}×</span>
|
||
${detection.gps && detection.gps.latitude !== undefined ? '<span class="gps-tag">GPS</span>' : ''}
|
||
</div>
|
||
${gpsLink}
|
||
</div>
|
||
<span class="detection-time">${lastSeen}</span>
|
||
</div>
|
||
<div class="detection-details">
|
||
${detailsHtml}
|
||
<div class="detail-item">
|
||
<span class="detail-label">Alias:</span>
|
||
<span class="alias-display" onclick="editAlias(${detection.id})">
|
||
${detection.alias || '<em>Click to add alias</em>'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function exportCSV(type = 'session') {
|
||
window.location.href = `/api/export/csv?type=${type}`;
|
||
closeExportDropdown();
|
||
}
|
||
|
||
function exportKML(type = 'session') {
|
||
window.location.href = `/api/export/kml?type=${type}`;
|
||
closeExportDropdown();
|
||
}
|
||
|
||
function toggleExportDropdown() {
|
||
const dropdown = document.getElementById('exportDropdown');
|
||
dropdown.style.display = dropdown.style.display === 'block' ? 'none' : 'block';
|
||
document.getElementById('importDropdown').style.display = 'none';
|
||
}
|
||
|
||
function toggleImportDropdown() {
|
||
const dropdown = document.getElementById('importDropdown');
|
||
dropdown.style.display = dropdown.style.display === 'block' ? 'none' : 'block';
|
||
document.getElementById('exportDropdown').style.display = 'none';
|
||
}
|
||
|
||
function closeExportDropdown() {
|
||
document.getElementById('exportDropdown').style.display = 'none';
|
||
}
|
||
|
||
function closeImportDropdown() {
|
||
document.getElementById('importDropdown').style.display = 'none';
|
||
}
|
||
|
||
let pendingImportType = 'json';
|
||
|
||
function importFile(type) {
|
||
pendingImportType = type;
|
||
const input = document.getElementById('importFileInput');
|
||
input.accept = type === 'json' ? '.json' : type === 'csv' ? '.csv' : '.kml';
|
||
input.click();
|
||
closeImportDropdown();
|
||
}
|
||
|
||
function handleImportFile() {
|
||
const input = document.getElementById('importFileInput');
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const endpoint = pendingImportType === 'json' ? '/api/import/json' : pendingImportType === 'csv' ? '/api/import/csv' : '/api/import/kml';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
alert(data.message);
|
||
loadDetections();
|
||
loadCumulativeDetections();
|
||
updateStats();
|
||
} else {
|
||
alert('Import error: ' + data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Import error:', error);
|
||
alert('Import failed: ' + error.message);
|
||
});
|
||
|
||
// Reset input so same file can be re-selected
|
||
input.value = '';
|
||
}
|
||
|
||
// Close dropdowns when clicking outside
|
||
document.addEventListener('click', function(event) {
|
||
const exportDD = document.querySelector('.export-dropdown');
|
||
const allDropdowns = document.querySelectorAll('.export-dropdown');
|
||
let clickedInside = false;
|
||
allDropdowns.forEach(dd => {
|
||
if (dd.contains(event.target)) clickedInside = true;
|
||
});
|
||
if (!clickedInside) {
|
||
closeExportDropdown();
|
||
closeImportDropdown();
|
||
}
|
||
});
|
||
|
||
function clearDetections() {
|
||
if (confirm('Are you sure you want to clear all detections?')) {
|
||
fetch('/api/clear', {
|
||
method: 'POST'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
detections = [];
|
||
updateStats();
|
||
renderDetections();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Socket connection events
|
||
socket.on('connect', function() {
|
||
console.log('Socket connected');
|
||
});
|
||
|
||
socket.on('disconnect', function() {
|
||
console.log('Socket disconnected');
|
||
});
|
||
|
||
socket.on('reconnect', function(attemptNumber) {
|
||
console.log('Socket reconnected after', attemptNumber, 'attempts');
|
||
// Reload data after reconnection
|
||
loadDetections();
|
||
loadStatus();
|
||
});
|
||
|
||
socket.on('reconnect_failed', function() {
|
||
console.log('Socket reconnection failed');
|
||
});
|
||
|
||
socket.on('heartbeat', function() {
|
||
socket.emit('heartbeat');
|
||
});
|
||
|
||
socket.on('heartbeat_ack', function() {
|
||
// Heartbeat acknowledged
|
||
});
|
||
|
||
// Socket.IO event handlers
|
||
socket.on('new_detection', function(detection) {
|
||
console.log('New detection received:', detection);
|
||
|
||
// Validate detection data
|
||
if (!detection || !detection.id) {
|
||
console.error('Invalid detection data received:', detection);
|
||
return;
|
||
}
|
||
|
||
// Check if detection already exists (prevent duplicates)
|
||
const existingIndex = detections.findIndex(d => d.id === detection.id);
|
||
if (existingIndex !== -1) {
|
||
console.log('Detection already exists, updating:', detection.id);
|
||
detections[existingIndex] = detection;
|
||
} else {
|
||
detections.unshift(detection);
|
||
}
|
||
|
||
updateStats();
|
||
renderDetections();
|
||
|
||
// Update map if visible
|
||
if (map && document.getElementById('mapContainer').style.display !== 'none') {
|
||
updateMapMarkers();
|
||
}
|
||
});
|
||
|
||
socket.on('detection_updated', function(detection) {
|
||
console.log('Detection updated:', detection);
|
||
|
||
// Validate detection data
|
||
if (!detection || !detection.id) {
|
||
console.error('Invalid detection update data received:', detection);
|
||
return;
|
||
}
|
||
|
||
// Update existing detection
|
||
const existingIndex = detections.findIndex(d => d.id === detection.id);
|
||
if (existingIndex !== -1) {
|
||
detections[existingIndex] = detection;
|
||
updateStats();
|
||
renderDetections();
|
||
|
||
// Update map if visible
|
||
if (map && document.getElementById('mapContainer').style.display !== 'none') {
|
||
updateMapMarkers();
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('gps_update', function(gpsData) {
|
||
console.log('GPS Update:', gpsData);
|
||
});
|
||
|
||
socket.on('gps_disconnected', function() {
|
||
console.log('GPS disconnected');
|
||
updateGpsStatus(false);
|
||
const connectBtn = document.getElementById('connectGpsBtn');
|
||
const disconnectBtn = document.getElementById('disconnectGpsBtn');
|
||
if (connectBtn) connectBtn.style.display = 'inline-block';
|
||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||
});
|
||
|
||
socket.on('flock_disconnected', function() {
|
||
console.log('Sniffer device disconnected');
|
||
updateFlockStatus(false);
|
||
const connectBtn = document.getElementById('connectFlockBtn');
|
||
const disconnectBtn = document.getElementById('disconnectFlockBtn');
|
||
if (connectBtn) connectBtn.style.display = 'inline-block';
|
||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||
});
|
||
|
||
socket.on('detections_cleared', function() {
|
||
console.log('All detections cleared');
|
||
detections = [];
|
||
updateStats();
|
||
renderDetections();
|
||
|
||
// Clear map markers
|
||
if (map) {
|
||
clearMapMarkers();
|
||
}
|
||
});
|
||
|
||
socket.on('flock_reconnected', function(data) {
|
||
console.log('Sniffer device reconnected:', data);
|
||
updateFlockStatus(true);
|
||
document.getElementById('connectFlockBtn').style.display = 'none';
|
||
document.getElementById('disconnectFlockBtn').style.display = 'inline-block';
|
||
if (document.getElementById('serialTerminalContainer').style.display !== 'none') {
|
||
addSerialLine(`Sniffer device reconnected on ${data.port}`, 'success');
|
||
}
|
||
});
|
||
|
||
socket.on('gps_reconnected', function(data) {
|
||
console.log('GPS device reconnected:', data);
|
||
updateGpsStatus(true);
|
||
document.getElementById('connectGpsBtn').style.display = 'none';
|
||
document.getElementById('disconnectGpsBtn').style.display = 'inline-block';
|
||
if (document.getElementById('serialTerminalContainer').style.display !== 'none') {
|
||
addSerialLine(`GPS device reconnected on ${data.port}`, 'success');
|
||
}
|
||
});
|
||
|
||
socket.on('reconnect_failed', function(data) {
|
||
console.log('Reconnection failed:', data);
|
||
const device = data.device === 'flock' ? 'Sniffer' : 'GPS';
|
||
if (document.getElementById('serialTerminalContainer').style.display !== 'none') {
|
||
addSerialLine(`${device} reconnection failed after ${max_reconnect_attempts} attempts`, 'error');
|
||
}
|
||
});
|
||
|
||
function toggleSerialTerminal() {
|
||
const container = document.getElementById('serialTerminalContainer');
|
||
const button = document.getElementById('serial-terminal-btn');
|
||
|
||
if (container.style.display === 'none') {
|
||
container.style.display = 'block';
|
||
button.textContent = 'Hide Terminal';
|
||
button.style.background = 'linear-gradient(135deg, #dc2626 0%, #ef4444 100%)';
|
||
|
||
// Start serial connection if device is connected
|
||
const flockPort = document.getElementById('flockDeviceSelect').value;
|
||
if (flockPort) {
|
||
startSerialConnection(flockPort);
|
||
} else {
|
||
addSerialLine('No Sniffer device selected', 'error');
|
||
}
|
||
} else {
|
||
container.style.display = 'none';
|
||
button.textContent = 'Serial Terminal';
|
||
button.style.background = 'linear-gradient(135deg, #059669 0%, #10b981 100%)';
|
||
}
|
||
}
|
||
|
||
function toggleOuiSearch() {
|
||
const container = document.getElementById('ouiSearchContainer');
|
||
const button = document.getElementById('oui-search-btn');
|
||
|
||
if (container.style.display === 'none') {
|
||
container.style.display = 'block';
|
||
button.textContent = 'Hide Search';
|
||
button.style.background = 'linear-gradient(135deg, #dc2626 0%, #ef4444 100%)';
|
||
} else {
|
||
container.style.display = 'none';
|
||
button.textContent = 'Search OUI';
|
||
button.style.background = 'linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%)';
|
||
}
|
||
}
|
||
|
||
function startSerialConnection(port) {
|
||
const statusElement = document.getElementById('serialConnectionStatus');
|
||
const outputElement = document.getElementById('serialTerminalOutput');
|
||
|
||
statusElement.textContent = 'Connecting...';
|
||
statusElement.style.color = '#fbbf24';
|
||
|
||
// Clear placeholder
|
||
if (outputElement.querySelector('.terminal-placeholder')) {
|
||
outputElement.innerHTML = '';
|
||
}
|
||
|
||
// Add connection message
|
||
addSerialLine(`Connecting to ${port} at 115200 baud...`, 'info');
|
||
|
||
// Request serial terminal connection
|
||
socket.emit('request_serial_terminal', {port: port});
|
||
|
||
// Set a timeout for connection
|
||
setTimeout(() => {
|
||
if (statusElement.textContent === 'Connecting...') {
|
||
statusElement.textContent = 'Connection Timeout';
|
||
statusElement.style.color = '#f87171';
|
||
addSerialLine('Connection timeout - check device connection', 'error');
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function addSerialLine(text, type = 'normal') {
|
||
console.log('Adding serial line:', text, 'type:', type);
|
||
|
||
// Store all terminal data
|
||
allTerminalData.push({ text, type, timestamp: Date.now() });
|
||
|
||
// Keep only last 1000 lines
|
||
if (allTerminalData.length > 1000) {
|
||
allTerminalData.shift();
|
||
}
|
||
|
||
// Apply current filter
|
||
if (shouldShowLine(text, type)) {
|
||
displaySerialLine(text, type);
|
||
}
|
||
}
|
||
|
||
function shouldShowLine(text, type) {
|
||
if (terminalFilter === 'all') return true;
|
||
|
||
const textLower = text.toLowerCase();
|
||
if (terminalFilter === 'gps' && (textLower.includes('gps') || textLower.includes('$gp') || textLower.includes('$gn') || textLower.includes('nmea'))) {
|
||
return true;
|
||
}
|
||
if (terminalFilter === 'sniffer' && !textLower.includes('gps') && (textLower.includes('detection') || textLower.includes('wifi') || textLower.includes('ble') || textLower.includes('mac') || textLower.includes('channel') || textLower.includes('rssi'))) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function displaySerialLine(text, type) {
|
||
const outputElement = document.getElementById('serialTerminalOutput');
|
||
|
||
if (!outputElement) {
|
||
console.error('Serial terminal output element not found');
|
||
return;
|
||
}
|
||
|
||
// Remove placeholder if it exists
|
||
const placeholder = outputElement.querySelector('.terminal-placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
const line = document.createElement('div');
|
||
line.className = `serial-line ${type}`;
|
||
line.textContent = text;
|
||
outputElement.appendChild(line);
|
||
outputElement.scrollTop = outputElement.scrollHeight;
|
||
}
|
||
|
||
function filterTerminalData() {
|
||
terminalFilter = document.getElementById('terminalFilter').value;
|
||
const outputElement = document.getElementById('serialTerminalOutput');
|
||
|
||
// Clear current display
|
||
outputElement.innerHTML = '';
|
||
|
||
// Re-display filtered data
|
||
allTerminalData.forEach(item => {
|
||
if (shouldShowLine(item.text, item.type)) {
|
||
displaySerialLine(item.text, item.type);
|
||
}
|
||
});
|
||
|
||
if (outputElement.children.length === 0) {
|
||
outputElement.innerHTML = '<div class="terminal-placeholder">No data matches current filter...</div>';
|
||
}
|
||
}
|
||
|
||
function clearSerialTerminal() {
|
||
allTerminalData = [];
|
||
const outputElement = document.getElementById('serialTerminalOutput');
|
||
outputElement.innerHTML = '<div class="terminal-placeholder">Terminal cleared...</div>';
|
||
}
|
||
|
||
// Serial terminal socket events
|
||
socket.on('serial_data', function(data) {
|
||
console.log('Received serial data:', data);
|
||
if (data && typeof data === 'string') {
|
||
addSerialLine(data, 'normal');
|
||
} else {
|
||
console.error('Invalid serial data received:', data);
|
||
}
|
||
});
|
||
|
||
socket.on('serial_connected', function() {
|
||
console.log('Serial terminal connected');
|
||
const statusElement = document.getElementById('serialConnectionStatus');
|
||
if (statusElement) {
|
||
statusElement.textContent = 'Connected';
|
||
statusElement.style.color = '#34d399';
|
||
}
|
||
addSerialLine('Serial connection established', 'success');
|
||
});
|
||
|
||
socket.on('serial_disconnected', function() {
|
||
console.log('Serial terminal disconnected');
|
||
const statusElement = document.getElementById('serialConnectionStatus');
|
||
if (statusElement) {
|
||
statusElement.textContent = 'Disconnected';
|
||
statusElement.style.color = '#f87171';
|
||
}
|
||
addSerialLine('Serial connection lost', 'error');
|
||
});
|
||
|
||
socket.on('serial_error', function(data) {
|
||
console.log('Serial error:', data);
|
||
if (data && data.message) {
|
||
addSerialLine(`Error: ${data.message}`, 'error');
|
||
} else {
|
||
addSerialLine('Unknown serial error', 'error');
|
||
}
|
||
});
|
||
|
||
|
||
|
||
socket.on('flock_reconnected', function(data) {
|
||
updateFlockStatus(true);
|
||
document.getElementById('connectFlockBtn').style.display = 'none';
|
||
document.getElementById('disconnectFlockBtn').style.display = 'inline-block';
|
||
addSerialLine(`Sniffer device reconnected on ${data.port}`, 'success');
|
||
});
|
||
|
||
socket.on('gps_reconnected', function(data) {
|
||
updateGpsStatus(true);
|
||
document.getElementById('connectGpsBtn').style.display = 'none';
|
||
document.getElementById('disconnectGpsBtn').style.display = 'inline-block';
|
||
addSerialLine(`GPS device reconnected on ${data.port}`, 'success');
|
||
});
|
||
|
||
socket.on('reconnect_failed', function(data) {
|
||
const device = data.device === 'flock' ? 'Sniffer' : 'GPS';
|
||
addSerialLine(`${device} reconnection failed after ${max_reconnect_attempts} attempts`, 'error');
|
||
});
|
||
|
||
// Alias editing functions
|
||
function editAlias(detectionId) {
|
||
const aliasDisplay = event.target;
|
||
const currentAlias = aliasDisplay.textContent.replace('<em>Click to add alias</em>', '').trim();
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'alias-input';
|
||
input.value = currentAlias;
|
||
input.placeholder = 'Enter alias...';
|
||
|
||
input.addEventListener('blur', function() {
|
||
saveAlias(detectionId, input.value);
|
||
});
|
||
|
||
input.addEventListener('keypress', function(e) {
|
||
if (e.key === 'Enter') {
|
||
saveAlias(detectionId, input.value);
|
||
}
|
||
});
|
||
|
||
aliasDisplay.innerHTML = '';
|
||
aliasDisplay.appendChild(input);
|
||
input.focus();
|
||
}
|
||
|
||
function saveAlias(detectionId, alias) {
|
||
fetch('/api/detection/alias', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
id: detectionId,
|
||
alias: alias
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
// Update the display
|
||
const aliasDisplay = event.target.parentElement;
|
||
aliasDisplay.innerHTML = alias || '<em>Click to add alias</em>';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error saving alias:', error);
|
||
});
|
||
}
|
||
|
||
// Handle detection updates
|
||
socket.on('detection_updated', function(detection) {
|
||
console.log('Detection updated:', detection);
|
||
// Update the detection in our local array
|
||
const index = detections.findIndex(d => d.id === detection.id);
|
||
if (index !== -1) {
|
||
detections[index] = detection;
|
||
updateStats();
|
||
renderDetections();
|
||
} else {
|
||
console.warn('Detection not found for update:', detection.id);
|
||
}
|
||
});
|
||
|
||
// OUI Search functions
|
||
function handleOuiSearch(event) {
|
||
if (event.key === 'Enter') {
|
||
searchOui();
|
||
}
|
||
}
|
||
|
||
function searchOui() {
|
||
const query = document.getElementById('ouiSearchInput').value.trim();
|
||
if (!query) {
|
||
return;
|
||
}
|
||
|
||
fetch('/api/oui/search', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ query: query })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
displayOuiResults(data.results);
|
||
})
|
||
.catch(error => {
|
||
console.error('Error searching OUI:', error);
|
||
displayOuiResults([]);
|
||
});
|
||
}
|
||
|
||
function displayOuiResults(results) {
|
||
const resultsContainer = document.getElementById('ouiSearchResults');
|
||
|
||
if (results.length === 0) {
|
||
resultsContainer.innerHTML = '<div class="search-placeholder">No results found</div>';
|
||
return;
|
||
}
|
||
|
||
// Check if this is a "view all" request (more than 50 results)
|
||
const isViewAll = results.length > 50;
|
||
|
||
if (isViewAll) {
|
||
// Use grid layout for view all - show all entries
|
||
const resultsHtml = results.map(result => `
|
||
<div class="oui-result-item">
|
||
<div class="oui-mac">${formatMacAddress(result.mac)}</div>
|
||
<div class="oui-manufacturer">${result.manufacturer}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
resultsContainer.innerHTML = `<div class="oui-results-grid">${resultsHtml}</div>`;
|
||
} else {
|
||
// Use single column for search results
|
||
const resultsHtml = results.map(result => `
|
||
<div class="oui-result-item">
|
||
<div class="oui-mac">${formatMacAddress(result.mac)}</div>
|
||
<div class="oui-manufacturer">${result.manufacturer}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
resultsContainer.innerHTML = resultsHtml;
|
||
}
|
||
}
|
||
|
||
function clearOuiResults() {
|
||
document.getElementById('ouiSearchInput').value = '';
|
||
document.getElementById('ouiSearchResults').innerHTML = '<div class="search-placeholder">Enter a MAC address or manufacturer name to search...</div>';
|
||
}
|
||
|
||
function formatMacAddress(mac) {
|
||
// Add colons every 2 characters
|
||
return mac.replace(/(.{2})(?=.{2})/g, '$1:');
|
||
}
|
||
|
||
function viewAllOui() {
|
||
fetch('/api/oui/all')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
displayOuiResults(data.results);
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading all OUI entries:', error);
|
||
displayOuiResults([]);
|
||
});
|
||
}
|
||
|
||
function refreshOuiDatabase() {
|
||
const button = document.querySelector('.refresh-db-btn');
|
||
const originalText = button.textContent;
|
||
button.textContent = 'Refreshing...';
|
||
button.disabled = true;
|
||
|
||
fetch('/api/oui/refresh', {
|
||
method: 'POST'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
alert(`Database refreshed successfully! Loaded ${data.count} entries.`);
|
||
} else {
|
||
alert('Error refreshing database: ' + data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error refreshing database:', error);
|
||
alert('Error refreshing database. Please try again.');
|
||
})
|
||
.finally(() => {
|
||
button.textContent = originalText;
|
||
button.disabled = false;
|
||
});
|
||
}
|
||
|
||
// Map functions
|
||
function toggleMap() {
|
||
const container = document.getElementById('mapContainer');
|
||
const button = document.getElementById('map-btn');
|
||
|
||
if (container.style.display === 'none') {
|
||
container.style.display = 'block';
|
||
button.textContent = 'Hide Map';
|
||
button.style.background = 'linear-gradient(135deg, #dc2626 0%, #ef4444 100%)';
|
||
|
||
if (!map) {
|
||
initializeMap();
|
||
}
|
||
// Load fresh cumulative data when opening map
|
||
loadCumulativeDetections();
|
||
updateMapMarkers();
|
||
} else {
|
||
container.style.display = 'none';
|
||
button.textContent = 'Map';
|
||
button.style.background = 'linear-gradient(135deg, #0891b2 0%, #06b6d4 100%)';
|
||
}
|
||
}
|
||
|
||
function initializeMap() {
|
||
// Initialize the map
|
||
map = L.map('map').setView([37.7749, -122.4194], 10); // Default to San Francisco
|
||
|
||
// Define map layers
|
||
mapLayers.osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors',
|
||
maxZoom: 19
|
||
});
|
||
|
||
mapLayers.satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||
attribution: '© Esri, DigitalGlobe, GeoEye, Earthstar Geographics, CNES/Airbus DS',
|
||
maxZoom: 19
|
||
});
|
||
|
||
mapLayers.topo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenTopoMap contributors',
|
||
maxZoom: 19
|
||
});
|
||
|
||
// Add default layer
|
||
mapLayers.osm.addTo(map);
|
||
}
|
||
|
||
function changeMapLayer() {
|
||
const selectedLayer = document.getElementById('mapLayer').value;
|
||
|
||
// Remove all layers
|
||
Object.values(mapLayers).forEach(layer => {
|
||
map.removeLayer(layer);
|
||
});
|
||
|
||
// Add selected layer
|
||
mapLayers[selectedLayer].addTo(map);
|
||
}
|
||
|
||
function updateMapMarkers() {
|
||
if (!map) return;
|
||
|
||
// Clear existing markers
|
||
clearMapMarkers();
|
||
|
||
// Get filtered detection data based on current filter
|
||
let detectionsToShow = [];
|
||
|
||
if (mapFilter === 'session') {
|
||
detectionsToShow = detections.filter(d => d.gps && d.gps.latitude && d.gps.longitude);
|
||
} else if (mapFilter === 'cumulative') {
|
||
detectionsToShow = cumulativeDetections.filter(d => d.gps && d.gps.latitude && d.gps.longitude);
|
||
} else if (mapFilter === 'both') {
|
||
// Combine session and cumulative, avoiding duplicates by MAC address
|
||
const sessionGps = detections.filter(d => d.gps && d.gps.latitude && d.gps.longitude);
|
||
const cumulativeGps = cumulativeDetections.filter(d => d.gps && d.gps.latitude && d.gps.longitude);
|
||
|
||
// Create a map of MAC addresses from session data
|
||
const sessionMacs = new Set(sessionGps.map(d => d.mac_address));
|
||
|
||
// Add session detections first
|
||
detectionsToShow = [...sessionGps];
|
||
|
||
// Add cumulative detections that aren't in current session
|
||
cumulativeGps.forEach(d => {
|
||
if (!sessionMacs.has(d.mac_address)) {
|
||
detectionsToShow.push(d);
|
||
}
|
||
});
|
||
}
|
||
|
||
detectionsToShow.forEach((detection, index) => {
|
||
const lat = detection.gps.latitude;
|
||
const lng = detection.gps.longitude;
|
||
|
||
// Determine if this is from session or cumulative
|
||
const isSessionData = detections.some(d => d.mac_address === detection.mac_address);
|
||
|
||
// Create custom icon based on detection type and data source
|
||
let iconColor = detection.protocol === 'wifi' ? '#ef4444' : '#3b82f6'; // red for wifi, blue for ble
|
||
let borderColor = isSessionData ? '#22c55e' : '#f59e0b'; // green border for session, orange for cumulative
|
||
|
||
const icon = L.divIcon({
|
||
className: 'custom-marker',
|
||
html: `<div style="background-color: ${iconColor}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid ${borderColor}; box-shadow: 0 0 4px rgba(0,0,0,0.5);"></div>`,
|
||
iconSize: [16, 16],
|
||
iconAnchor: [8, 8]
|
||
});
|
||
|
||
const marker = L.marker([lat, lng], { icon }).addTo(map);
|
||
|
||
// Create popup content with data source indicator
|
||
const dataSource = isSessionData ? 'Session' : 'Cumulative';
|
||
const aliasText = detection.alias ? `<strong>Alias:</strong> ${detection.alias}<br>` : '';
|
||
|
||
// GPS accuracy indicator
|
||
let gpsAccuracy = '';
|
||
if (detection.gps.time_diff !== undefined && detection.gps.time_diff !== null) {
|
||
const timeDiff = detection.gps.time_diff;
|
||
if (timeDiff < 5) {
|
||
gpsAccuracy = ` <span style="color: #22c55e;">✓ Precise (${timeDiff.toFixed(1)}s)</span>`;
|
||
} else if (timeDiff < 15) {
|
||
gpsAccuracy = ` <span style="color: #f59e0b;">~ Good (${timeDiff.toFixed(1)}s)</span>`;
|
||
} else {
|
||
gpsAccuracy = ` <span style="color: #ef4444;">⚠ Approximate (${timeDiff.toFixed(1)}s)</span>`;
|
||
}
|
||
} else {
|
||
gpsAccuracy = ` <span style="color: #6b7280;">? Unknown accuracy</span>`;
|
||
}
|
||
|
||
const popupContent = `
|
||
<h3>${detection.alias || `Detection #${detection.id}`} (${dataSource})</h3>
|
||
${aliasText}
|
||
<strong>Protocol:</strong> ${detection.protocol}<br>
|
||
<strong>Method:</strong> ${detection.detection_method}<br>
|
||
<strong>MAC:</strong> ${detection.mac_address}<br>
|
||
${detection.ssid ? `<strong>SSID:</strong> ${detection.ssid}<br>` : ''}
|
||
${detection.manufacturer ? `<strong>Manufacturer:</strong> ${detection.manufacturer}<br>` : ''}
|
||
<strong>RSSI:</strong> ${detection.last_rssi || detection.rssi} dBm<br>
|
||
<strong>GPS:</strong> ${lat.toFixed(6)}, ${lng.toFixed(6)}${gpsAccuracy}<br>
|
||
<strong>Satellites:</strong> ${detection.gps.satellites}<br>
|
||
<strong>Count:</strong> ${detection.detection_count || 1}<br>
|
||
<strong>Source:</strong> ${dataSource} Data
|
||
`;
|
||
|
||
marker.bindPopup(popupContent);
|
||
mapMarkers.push(marker);
|
||
});
|
||
|
||
// Update detection count
|
||
document.getElementById('mapDetectionCount').textContent = detectionsToShow.length;
|
||
|
||
// Fit map to markers if any exist
|
||
if (mapMarkers.length > 0) {
|
||
const group = new L.featureGroup(mapMarkers);
|
||
map.fitBounds(group.getBounds().pad(0.1));
|
||
}
|
||
}
|
||
|
||
function filterMapData() {
|
||
mapFilter = document.getElementById('mapFilter').value;
|
||
updateMapMarkers();
|
||
}
|
||
|
||
function loadCumulativeDetections() {
|
||
fetch('/api/stats')
|
||
.then(response => response.json())
|
||
.then(stats => {
|
||
// Load cumulative detections from server if available
|
||
// For now, we'll fetch them via a new endpoint
|
||
return fetch('/api/detections?type=cumulative');
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
cumulativeDetections = data;
|
||
console.log('Loaded cumulative detections:', cumulativeDetections.length);
|
||
|
||
// Update map if it's open and showing cumulative data
|
||
if (map && document.getElementById('mapContainer').style.display !== 'none') {
|
||
updateMapMarkers();
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading cumulative detections:', error);
|
||
cumulativeDetections = [];
|
||
});
|
||
}
|
||
|
||
function clearMapMarkers() {
|
||
mapMarkers.forEach(marker => {
|
||
map.removeLayer(marker);
|
||
});
|
||
mapMarkers = [];
|
||
document.getElementById('mapDetectionCount').textContent = '0';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|