mirror of
https://github.com/colonelpanichacks/flock-you.git
synced 2026-06-09 21:53:31 -07:00
3bf6f6a5bd
flockyou.py: bind host and listen port both read from env (FLOCKYOU_HOST / FLOCKYOU_PORT, defaulting to 0.0.0.0:5000). Was a hardcoded :5000 before, which collides with other Flask projects on the same dev box — now you can run side-by-side instances or bind to localhost only. index.html: ?demo=1 query param seeds the dashboard with eight synthetic detections covering every visual state (live wildcard-probe, addr2, addr1 with/without GPS, replay/FLASH with/without GPS, replay/ RAM × 2) and reveals the device command toolbar so the polished layout is browseable without flashing real hardware. Purely a front-end mock — the command buttons still hit the real /api/flock/* endpoints, so clicking them in demo mode produces the actual "device not connected" error toast (that error path is itself part of the UI worth seeing). Self-contained: a single initDemoMode() function gated on the query param, called once at the end of DOMContentLoaded. api/README.md: both features documented under Quick start so they're discoverable without reading the source.
3511 lines
129 KiB
HTML
3511 lines
129 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;
|
||
}
|
||
|
||
/* Replay badges (FLASH = SPIFFS, RAM = in-memory dump) */
|
||
.replay-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
|
||
color: white;
|
||
padding: 0.15rem 0.45rem;
|
||
border-radius: 3px;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
box-shadow: 0 0 8px rgba(99, 102, 241, 0.4);
|
||
}
|
||
.replay-badge.live {
|
||
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%);
|
||
box-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
|
||
}
|
||
.replay-badge svg {
|
||
width: 10px;
|
||
height: 10px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 2.5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.detection-item.replay {
|
||
border-left: 3px solid #6366f1;
|
||
background: linear-gradient(90deg, rgba(99, 102, 241, 0.08) 0%, transparent 30%);
|
||
}
|
||
.detection-item.replay.live-source {
|
||
border-left-color: #0ea5e9;
|
||
background: linear-gradient(90deg, rgba(14, 165, 233, 0.08) 0%, transparent 30%);
|
||
}
|
||
|
||
/* =====================================================
|
||
Device command toolbar — sticky strip below header
|
||
===================================================== */
|
||
.flock-command-bar {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 50;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.55rem 1rem;
|
||
background: linear-gradient(180deg, rgba(20, 12, 40, 0.96) 0%, rgba(15, 9, 30, 0.96) 100%);
|
||
border-bottom: 1px solid rgba(139, 92, 246, 0.35);
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
color: #e2e8f0;
|
||
font-size: 0.82rem;
|
||
transform: translateY(-100%);
|
||
opacity: 0;
|
||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||
pointer-events: none;
|
||
}
|
||
.flock-command-bar.show {
|
||
transform: translateY(0);
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.fcb-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
padding: 0.2rem 0.5rem;
|
||
font-family: 'Orbitron', 'Courier New', monospace;
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
letter-spacing: 1.5px;
|
||
color: #c4b5fd;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
.fcb-label svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
stroke: #c4b5fd;
|
||
fill: none;
|
||
stroke-width: 2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.fcb-section {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.fcb-divider {
|
||
width: 1px;
|
||
align-self: stretch;
|
||
background: linear-gradient(180deg, transparent, rgba(139, 92, 246, 0.35), transparent);
|
||
margin: 0 0.25rem;
|
||
}
|
||
|
||
.fcb-spacer { flex: 1; }
|
||
|
||
.fcb-status-dot {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.25rem 0.55rem;
|
||
border-radius: 999px;
|
||
background: rgba(34, 197, 94, 0.12);
|
||
border: 1px solid rgba(34, 197, 94, 0.4);
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
color: #4ade80;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.fcb-status-dot::before {
|
||
content: "";
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #22c55e;
|
||
box-shadow: 0 0 6px #22c55e;
|
||
animation: fcb-pulse 1.8s ease-in-out infinite;
|
||
}
|
||
@keyframes fcb-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
.fcb-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.4rem 0.75rem;
|
||
border-radius: 6px;
|
||
border: 1px solid transparent;
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, opacity 0.15s ease;
|
||
color: white;
|
||
white-space: nowrap;
|
||
}
|
||
.fcb-btn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
flex-shrink: 0;
|
||
}
|
||
.fcb-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||
}
|
||
.fcb-btn:active { transform: translateY(0); }
|
||
.fcb-btn:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
.fcb-btn .fcb-btn-count {
|
||
display: inline-block;
|
||
min-width: 1.6rem;
|
||
padding: 0 0.35rem;
|
||
margin-left: 0.1rem;
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
}
|
||
/* Pull Prev — purple/indigo, matches FLASH badge */
|
||
.fcb-btn.fcb-pull-prev {
|
||
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
|
||
border-color: #4338ca;
|
||
}
|
||
.fcb-btn.fcb-pull-prev:hover {
|
||
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
|
||
}
|
||
/* Pull Live — cyan, matches RAM badge */
|
||
.fcb-btn.fcb-pull-live {
|
||
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%);
|
||
border-color: #0369a1;
|
||
}
|
||
.fcb-btn.fcb-pull-live:hover {
|
||
background: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%);
|
||
}
|
||
/* Status — slate (neutral info) */
|
||
.fcb-btn.fcb-status {
|
||
background: linear-gradient(135deg, #475569 0%, #64748b 100%);
|
||
border-color: #334155;
|
||
}
|
||
.fcb-btn.fcb-status:hover {
|
||
background: linear-gradient(135deg, #64748b 0%, #94a3b8 100%);
|
||
}
|
||
/* Clear — red (destructive). Subtler shade than the connect/disconnect danger
|
||
so the destructive ops don't dominate the bar. */
|
||
.fcb-btn.fcb-danger {
|
||
background: linear-gradient(135deg, #991b1b 0%, #b91c1c 100%);
|
||
border-color: #7f1d1d;
|
||
}
|
||
.fcb-btn.fcb-danger:hover {
|
||
background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%);
|
||
}
|
||
|
||
/* Toast — fixed top-right notification stack */
|
||
#flockToast {
|
||
position: fixed;
|
||
top: 1rem;
|
||
right: 1rem;
|
||
min-width: 280px;
|
||
max-width: 420px;
|
||
padding: 0.85rem 1rem 0.85rem 1.1rem;
|
||
border-radius: 8px;
|
||
background: rgba(15, 9, 30, 0.97);
|
||
color: #e2e8f0;
|
||
border: 1px solid rgba(139, 92, 246, 0.35);
|
||
border-left: 4px solid #6366f1;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
|
||
font-size: 0.88rem;
|
||
line-height: 1.4;
|
||
z-index: 9999;
|
||
opacity: 0;
|
||
transform: translate(20px, -10px);
|
||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||
pointer-events: none;
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
}
|
||
#flockToast.show {
|
||
opacity: 1;
|
||
transform: translate(0, 0);
|
||
}
|
||
#flockToast.success {
|
||
border-left-color: #22c55e;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.45), 0 0 18px rgba(34, 197, 94, 0.2);
|
||
}
|
||
#flockToast.warning {
|
||
border-left-color: #f59e0b;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.45), 0 0 18px rgba(245, 158, 11, 0.2);
|
||
}
|
||
#flockToast.error {
|
||
border-left-color: #ef4444;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.45), 0 0 18px rgba(239, 68, 68, 0.25);
|
||
}
|
||
#flockToast.info {
|
||
border-left-color: #6366f1;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.45), 0 0 18px rgba(99, 102, 241, 0.2);
|
||
}
|
||
|
||
@media (max-width: 800px) {
|
||
.flock-command-bar {
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
.fcb-divider, .fcb-spacer, .fcb-status-dot { display: none; }
|
||
.fcb-label { font-size: 0.65rem; }
|
||
.fcb-btn { font-size: 0.72rem; padding: 0.35rem 0.6rem; }
|
||
}
|
||
|
||
.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 id="flockToast" role="status" aria-live="polite"></div>
|
||
|
||
<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 id="flockCommandBar" class="flock-command-bar" role="toolbar" aria-label="Device commands">
|
||
<div class="fcb-label">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="1.5"/><rect x="10" y="10" width="4" height="4"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/></svg>
|
||
<span>Device</span>
|
||
</div>
|
||
<div class="fcb-status-dot" id="fcbStatusDot" title="Connected to Sniffer device">Online</div>
|
||
<div class="fcb-divider"></div>
|
||
<div class="fcb-section">
|
||
<button id="dumpPrevBtn" class="fcb-btn fcb-pull-prev"
|
||
title="Pull /prev_session.json from device SPIFFS (CMD:DUMP_PREV)"
|
||
onclick="flockDumpPrev()">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||
<span class="fcb-btn-label">Pull Prev</span>
|
||
</button>
|
||
<button id="dumpLiveBtn" class="fcb-btn fcb-pull-live"
|
||
title="Pull the device's in-RAM detection table (CMD:DUMP_LIVE)"
|
||
onclick="flockDumpLive()">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
<span class="fcb-btn-label">Pull Live</span>
|
||
</button>
|
||
<button id="flockStatusBtn" class="fcb-btn fcb-status"
|
||
title="Query device status (CMD:STATUS)"
|
||
onclick="flockStatusQuery()">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||
<span class="fcb-btn-label">Status</span>
|
||
</button>
|
||
</div>
|
||
<div class="fcb-spacer"></div>
|
||
<div class="fcb-divider"></div>
|
||
<div class="fcb-section">
|
||
<button id="clearPrevBtn" class="fcb-btn fcb-danger"
|
||
title="Delete /prev_session.json on the device (CMD:CLEAR_PREV)"
|
||
onclick="flockClearPrev()">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
<span class="fcb-btn-label">Clear Prev</span>
|
||
</button>
|
||
<button id="clearLiveBtn" class="fcb-btn fcb-danger"
|
||
title="Wipe the device's in-RAM detection table (CMD:CLEAR_LIVE)"
|
||
onclick="flockClearLive()">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
<span class="fcb-btn-label">Clear Live</span>
|
||
</button>
|
||
</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);
|
||
|
||
initDemoMode();
|
||
});
|
||
|
||
// ============================================================
|
||
// Demo mode — ?demo=1
|
||
//
|
||
// Seeds the dashboard with synthetic detections covering every
|
||
// visual state (live, replay/FLASH, replay/RAM, with and without
|
||
// GPS) and reveals the device command toolbar so the polished
|
||
// layout is browseable without flashing real hardware.
|
||
//
|
||
// Purely a front-end mock. The command buttons still hit the
|
||
// real /api/flock/* endpoints, so clicking them produces a
|
||
// realistic "Flock device not connected" error toast — that
|
||
// failure path is itself part of the UI worth previewing.
|
||
// ============================================================
|
||
function initDemoMode() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (!params.has('demo')) return;
|
||
|
||
updateFlockStatus(true);
|
||
setFlockExtraControls(true);
|
||
|
||
const sampleManufacturer = 'Espressif / Flock Safety';
|
||
const now = new Date();
|
||
const tIso = (deltaMs) => new Date(now.getTime() - deltaMs).toISOString();
|
||
const tFmt = (deltaMs) => new Date(now.getTime() - deltaMs).toLocaleTimeString();
|
||
|
||
const samples = [
|
||
{
|
||
detection_method: 'wifi_wildcard_probe', protocol: 'wifi',
|
||
mac_address: '70:c9:4e:81:b3:24', oui: '70:c9:4e',
|
||
rssi: -52, last_rssi: -49, channel: 6, last_channel: 6,
|
||
detection_count: 23, ssid: '',
|
||
gps: { latitude: 37.3349, longitude: -122.0090, fix_quality: 1, satellites: 8, match_quality: 'temporal', time_diff: 1.2 },
|
||
},
|
||
{
|
||
detection_method: 'wifi_oui_addr2', protocol: 'wifi',
|
||
mac_address: '3c:91:80:14:f2:8a', oui: '3c:91:80',
|
||
rssi: -67, last_rssi: -71, channel: 1, last_channel: 1,
|
||
detection_count: 12, ssid: '',
|
||
},
|
||
{
|
||
detection_method: 'wifi_oui_addr1', protocol: 'wifi',
|
||
mac_address: 'd8:f3:bc:09:c1:5e', oui: 'd8:f3:bc',
|
||
rssi: -78, last_rssi: -75, channel: 11, last_channel: 11,
|
||
detection_count: 4, ssid: '',
|
||
},
|
||
{
|
||
detection_method: 'wifi_wildcard_probe', protocol: 'wifi',
|
||
mac_address: '82:6b:f2:7a:11:88', oui: '82:6b:f2',
|
||
rssi: -61, last_rssi: -58, channel: 6, last_channel: 6,
|
||
detection_count: 1, ssid: '',
|
||
alias: 'DeFlockJoplin 12th cam',
|
||
},
|
||
{
|
||
detection_method: 'wifi_oui_addr2', protocol: 'wifi',
|
||
mac_address: 'b8:35:32:c0:de:01', oui: 'b8:35:32',
|
||
rssi: -82, channel: 1, detection_count: 47, ssid: '',
|
||
replay: true, replay_source: 'prev',
|
||
timestamp_source: 'device_replay',
|
||
device_first_ms: 12345, device_last_ms: 873400,
|
||
},
|
||
{
|
||
detection_method: 'wifi_oui_addr1', protocol: 'wifi',
|
||
mac_address: '14:b5:cd:33:9f:21', oui: '14:b5:cd',
|
||
rssi: -74, channel: 11, detection_count: 8, ssid: '',
|
||
replay: true, replay_source: 'prev',
|
||
timestamp_source: 'device_replay',
|
||
device_first_ms: 91100, device_last_ms: 902340,
|
||
gps: { latitude: 37.4419, longitude: -122.1430, fix_quality: 1, satellites: 7, match_quality: 'temporal', time_diff: 3.8 },
|
||
},
|
||
{
|
||
detection_method: 'wifi_oui_addr2', protocol: 'wifi',
|
||
mac_address: '94:2a:6f:0e:44:b2', oui: '94:2a:6f',
|
||
rssi: -69, channel: 6, detection_count: 3, ssid: '',
|
||
replay: true, replay_source: 'live',
|
||
timestamp_source: 'device_replay',
|
||
device_first_ms: 1003000, device_last_ms: 1004900,
|
||
},
|
||
{
|
||
detection_method: 'wifi_wildcard_probe', protocol: 'wifi',
|
||
mac_address: 'f4:e2:c6:55:71:0d', oui: 'f4:e2:c6',
|
||
rssi: -64, channel: 11, detection_count: 16, ssid: '',
|
||
replay: true, replay_source: 'live',
|
||
timestamp_source: 'device_replay',
|
||
device_first_ms: 1010500, device_last_ms: 1080000,
|
||
},
|
||
];
|
||
samples.forEach((s, i) => {
|
||
s.id = 9000 + i;
|
||
s.manufacturer = sampleManufacturer;
|
||
s.alias = s.alias || '';
|
||
if (!s.replay) {
|
||
s.first_seen = tIso((samples.length - i) * 23_000);
|
||
s.last_seen = tIso(i * 4_000);
|
||
s.detection_time = tFmt(i * 4_000);
|
||
s.timestamp = tIso(i * 4_000);
|
||
s.timestamp_source = 'system';
|
||
}
|
||
detections.push(s);
|
||
cumulativeDetections.push(s);
|
||
});
|
||
|
||
updateStats();
|
||
renderDetections();
|
||
showFlockToast(
|
||
'Demo mode active. Sample detections seeded; command buttons will toast errors (no real device).',
|
||
'info', 7000);
|
||
}
|
||
|
||
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';
|
||
setFlockExtraControls(true);
|
||
} 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';
|
||
setFlockExtraControls(false);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Flock disconnect error:', error);
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// Host command protocol UI (CMD:STATUS / CMD:DUMP_* / CMD:CLEAR_*)
|
||
// ============================================================
|
||
|
||
let flockToastTimer = null;
|
||
function showFlockToast(message, kind = 'info', timeout = 4000) {
|
||
const el = document.getElementById('flockToast');
|
||
if (!el) return;
|
||
el.textContent = message;
|
||
el.classList.remove('success', 'warning', 'error', 'info');
|
||
el.classList.add(kind);
|
||
el.classList.add('show');
|
||
if (flockToastTimer) clearTimeout(flockToastTimer);
|
||
if (timeout > 0) {
|
||
flockToastTimer = setTimeout(() => { el.classList.remove('show'); }, timeout);
|
||
}
|
||
}
|
||
|
||
function setFlockExtraControls(visible) {
|
||
const el = document.getElementById('flockCommandBar');
|
||
if (!el) return;
|
||
if (visible) el.classList.add('show');
|
||
else el.classList.remove('show');
|
||
}
|
||
|
||
function setFlockCmdButtonsBusy(busy, exceptId = null) {
|
||
['dumpPrevBtn', 'dumpLiveBtn', 'flockStatusBtn', 'clearPrevBtn', 'clearLiveBtn'].forEach(id => {
|
||
const btn = document.getElementById(id);
|
||
if (!btn) return;
|
||
if (busy && id !== exceptId) btn.disabled = true;
|
||
else btn.disabled = false;
|
||
});
|
||
}
|
||
|
||
// Per-pull progress — bumped by every replay_detection socket event.
|
||
// The protocol serializes one CMD:* at a time, so at most one pull is
|
||
// in flight; a single global state is enough.
|
||
const pullProgress = { active: null, count: 0, btnId: null, label: null };
|
||
|
||
function pullProgressStart(source, btnId, label) {
|
||
pullProgress.active = source;
|
||
pullProgress.count = 0;
|
||
pullProgress.btnId = btnId;
|
||
pullProgress.label = label;
|
||
pullProgressRender();
|
||
}
|
||
function pullProgressBump() {
|
||
if (!pullProgress.active) return;
|
||
pullProgress.count += 1;
|
||
pullProgressRender();
|
||
}
|
||
function pullProgressEnd() {
|
||
pullProgress.active = null;
|
||
pullProgress.count = 0;
|
||
pullProgress.btnId = null;
|
||
pullProgress.label = null;
|
||
}
|
||
function pullProgressRender() {
|
||
const btn = document.getElementById(pullProgress.btnId);
|
||
if (!btn) return;
|
||
const labelEl = btn.querySelector('.fcb-btn-label');
|
||
if (!labelEl) return;
|
||
const countBadge = pullProgress.count > 0
|
||
? `<span class="fcb-btn-count">${pullProgress.count}</span>`
|
||
: '';
|
||
labelEl.innerHTML = `Pulling${countBadge}`;
|
||
}
|
||
|
||
function flockPullSession(source) {
|
||
const isPrev = source === 'prev';
|
||
const btnId = isPrev ? 'dumpPrevBtn' : 'dumpLiveBtn';
|
||
const btn = document.getElementById(btnId);
|
||
if (!btn) return;
|
||
const labelEl = btn.querySelector('.fcb-btn-label');
|
||
const originalLabel = labelEl ? labelEl.textContent : (isPrev ? 'Pull Prev' : 'Pull Live');
|
||
|
||
setFlockCmdButtonsBusy(true, btnId);
|
||
pullProgressStart(source, btnId, originalLabel);
|
||
showFlockToast(`Pulling ${isPrev ? 'previous session' : 'live table'} from device…`, 'info', 0);
|
||
|
||
fetch(`/api/flock/dump_${source}`, { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setFlockCmdButtonsBusy(false);
|
||
if (labelEl) labelEl.textContent = originalLabel;
|
||
const finalCount = data.count ?? pullProgress.count;
|
||
pullProgressEnd();
|
||
if (data.status === 'success') {
|
||
const reason = data.reason ? ` (${data.reason})` : '';
|
||
showFlockToast(
|
||
`Pulled ${finalCount} detection${finalCount === 1 ? '' : 's'} from device ${isPrev ? 'flash' : 'RAM'}${reason}`,
|
||
finalCount > 0 ? 'success' : 'warning');
|
||
} else {
|
||
const reason = data.reason || data.message || 'unknown error';
|
||
showFlockToast(`Pull failed: ${reason}`, 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
setFlockCmdButtonsBusy(false);
|
||
if (labelEl) labelEl.textContent = originalLabel;
|
||
pullProgressEnd();
|
||
showFlockToast(`Pull failed: ${err.message}`, 'error');
|
||
});
|
||
}
|
||
|
||
function flockDumpPrev() { flockPullSession('prev'); }
|
||
function flockDumpLive() { flockPullSession('live'); }
|
||
|
||
function fcbBtnLabel(btnId) {
|
||
const btn = document.getElementById(btnId);
|
||
return btn ? btn.querySelector('.fcb-btn-label') : null;
|
||
}
|
||
|
||
function flockStatusQuery() {
|
||
const labelEl = fcbBtnLabel('flockStatusBtn');
|
||
const btn = document.getElementById('flockStatusBtn');
|
||
const original = labelEl ? labelEl.textContent : 'Status';
|
||
btn.disabled = true;
|
||
if (labelEl) labelEl.textContent = 'Querying…';
|
||
fetch('/api/flock/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
btn.disabled = false;
|
||
if (labelEl) labelEl.textContent = original;
|
||
if (data.status === 'success' && data.firmware_status) {
|
||
const s = data.firmware_status;
|
||
const heapKB = s.free_heap ? Math.round(s.free_heap / 1024) : '?';
|
||
const uptimeSec = s.uptime_ms ? Math.round(s.uptime_ms / 1000) : 0;
|
||
showFlockToast(
|
||
`det=${s.fy_det} · ouis=${s.oui_count} · prev=${s.prev_session ? 'yes' : 'no'} · ` +
|
||
`ch=${s.channel} · heap=${heapKB}KB · up=${uptimeSec}s`,
|
||
'success', 6000);
|
||
} else {
|
||
showFlockToast(`Status failed: ${data.message || 'no response'}`, 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
btn.disabled = false;
|
||
if (labelEl) labelEl.textContent = original;
|
||
showFlockToast(`Status failed: ${err.message}`, 'error');
|
||
});
|
||
}
|
||
|
||
function flockClearSession(target) {
|
||
const label = target === 'prev' ? 'previous session on device flash' : 'live detection table on device';
|
||
if (!confirm(`Wipe the ${label}?`)) return;
|
||
const btnId = target === 'prev' ? 'clearPrevBtn' : 'clearLiveBtn';
|
||
const btn = document.getElementById(btnId);
|
||
const labelEl = fcbBtnLabel(btnId);
|
||
const original = labelEl ? labelEl.textContent : (target === 'prev' ? 'Clear Prev' : 'Clear Live');
|
||
btn.disabled = true;
|
||
if (labelEl) labelEl.textContent = 'Clearing…';
|
||
fetch(`/api/flock/clear_${target}`, { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
btn.disabled = false;
|
||
if (labelEl) labelEl.textContent = original;
|
||
if (data.status === 'success') {
|
||
showFlockToast(`Cleared device ${target === 'prev' ? '/prev_session.json' : 'in-RAM table'}`, 'success');
|
||
} else {
|
||
showFlockToast(`Clear failed: ${(data.firmware && data.firmware.reason) || data.message || 'unknown'}`, 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
btn.disabled = false;
|
||
if (labelEl) labelEl.textContent = original;
|
||
showFlockToast(`Clear failed: ${err.message}`, 'error');
|
||
});
|
||
}
|
||
function flockClearPrev() { flockClearSession('prev'); }
|
||
function flockClearLive() { flockClearSession('live'); }
|
||
|
||
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';
|
||
setFlockExtraControls(true);
|
||
}
|
||
|
||
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>`;
|
||
}
|
||
|
||
const isReplay = detection.replay === true || detection.timestamp_source === 'device_replay';
|
||
const replaySource = detection.replay_source || (isReplay ? 'device' : null);
|
||
const isLiveSrc = replaySource === 'live';
|
||
// Inline icons: a small chip glyph for FLASH, a wave for RAM.
|
||
const replayIcon = isLiveSrc
|
||
? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h3l2 -6 4 12 2 -6h7"/></svg>'
|
||
: '<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v6c0 1.66 4.03 3 9 3s9 -1.34 9 -3v-6"/><path d="M3 11v6c0 1.66 4.03 3 9 3s9 -1.34 9 -3v-6"/></svg>';
|
||
const replayBadge = isReplay
|
||
? `<span class="replay-badge ${isLiveSrc ? 'live' : ''}" title="From device ${isLiveSrc ? 'in-RAM table (CMD:DUMP_LIVE)' : 'SPIFFS / prev_session.json (CMD:DUMP_PREV)'}">${replayIcon}<span>${isLiveSrc ? 'RAM' : 'FLASH'}</span></span>`
|
||
: '';
|
||
|
||
const replayClass = isReplay ? (isLiveSrc ? ' replay live-source' : ' replay') : '';
|
||
|
||
return `
|
||
<div class="detection-item${replayClass}">
|
||
<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>' : ''}
|
||
${replayBadge}
|
||
</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';
|
||
setFlockExtraControls(false);
|
||
});
|
||
|
||
// ============================================================
|
||
// Server-side command protocol events (from add_replay_detection
|
||
// and handle_command_event in flockyou.py).
|
||
// ============================================================
|
||
socket.on('replay_detection', function(detection) {
|
||
// Treat replayed detections like new ones for the in-memory
|
||
// list, but flag them so renderDetections shows the badge.
|
||
if (!detections.find(d => d.id === detection.id)) {
|
||
detections.push(detection);
|
||
cumulativeDetections.push(detection);
|
||
}
|
||
// Live progress counter on the in-flight Pull button
|
||
pullProgressBump();
|
||
updateStats();
|
||
renderDetections();
|
||
});
|
||
|
||
socket.on('flock_replay_complete', function(info) {
|
||
const reason = info && info.reason ? ` (${info.reason})` : '';
|
||
const src = info && info.source ? info.source : 'device';
|
||
const ok = info && info.ok;
|
||
if (ok) {
|
||
showFlockToast(`Replay complete: ${info.count || 0} entries from ${src}${reason}`,
|
||
(info.count || 0) > 0 ? 'success' : 'warning');
|
||
} else {
|
||
showFlockToast(`Replay failed${reason}`, 'error');
|
||
}
|
||
});
|
||
|
||
socket.on('flock_status', function(info) {
|
||
// The /api/flock/status REST call also gets this payload via the
|
||
// synchronous response. The socket event is for any other client
|
||
// tabs that should reflect the same state — no popup needed.
|
||
console.log('Flock status broadcast:', info);
|
||
});
|
||
|
||
socket.on('flock_clear', function(info) {
|
||
console.log('Flock clear broadcast:', info);
|
||
});
|
||
|
||
socket.on('flock_error', function(info) {
|
||
const reason = (info && info.reason) || 'unknown';
|
||
const cmd = (info && info.cmd) ? ` (${info.cmd})` : '';
|
||
showFlockToast(`Device error: ${reason}${cmd}`, 'error');
|
||
});
|
||
|
||
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>
|