Files
flock-you/api/templates/index.html
Colonel Panic 3606f1f812 Overhaul firmware: BLE-only detection, web dashboard, GPS wardriving, session persistence
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
2026-02-07 12:53:58 -05:00

2847 lines
100 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>