feat: add multi-node master dashboard
Enable a designated "master" node to view device data from any peer node within the same dashboard, without page redirects. - Add is_master config option to ScannerConfig - Add proxy endpoints /api/node/<id>/* for peer data - Add node selector dropdown UI (shown only on master) - Add peer WebSocket connection support for real-time updates - Live tracking uses node-specific scan endpoint - Map centers on selected peer's position Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -37,6 +37,7 @@
|
||||
- [x] WebSocket real-time sync (instead of polling)
|
||||
- [ ] Automatic peer discovery via mDNS/Bonjour
|
||||
- [x] Sync RSSI history for trilateration
|
||||
- [x] Master dashboard: view peer node data without redirect
|
||||
- [ ] Web UI for peer management
|
||||
- [ ] Sync conflict resolution UI
|
||||
|
||||
@@ -248,6 +249,7 @@
|
||||
- [x] Termux/Android environment detection with prerequisite checks
|
||||
- [x] Multi-scanner trilateration for device positioning
|
||||
- [x] Signal coverage heat map visualization
|
||||
- [x] Multi-node master dashboard (view peer data in single UI)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class ScannerConfig:
|
||||
latitude: float | None = None # Scanner position (falls back to gps.latitude)
|
||||
longitude: float | None = None # Scanner position (falls back to gps.longitude)
|
||||
floor: int | None = None # Scanner's floor (falls back to building.current_floor)
|
||||
is_master: bool = False # Master node can view other nodes' data in dashboard
|
||||
|
||||
# Scanning configuration
|
||||
wifi_interface: str = "wlan0"
|
||||
@@ -179,6 +180,7 @@ class Config:
|
||||
latitude=data["scanner"].get("latitude", config.scanner.latitude),
|
||||
longitude=data["scanner"].get("longitude", config.scanner.longitude),
|
||||
floor=data["scanner"].get("floor", config.scanner.floor),
|
||||
is_master=data["scanner"].get("is_master", config.scanner.is_master),
|
||||
# Scanning configuration
|
||||
wifi_interface=data["scanner"].get("wifi_interface", config.scanner.wifi_interface),
|
||||
bt_scan_timeout=data["scanner"].get("bt_scan_timeout", config.scanner.bt_scan_timeout),
|
||||
@@ -330,6 +332,7 @@ class Config:
|
||||
"latitude": self.scanner.latitude,
|
||||
"longitude": self.scanner.longitude,
|
||||
"floor": self.scanner.floor,
|
||||
"is_master": self.scanner.is_master,
|
||||
# Scanning configuration
|
||||
"wifi_interface": self.scanner.wifi_interface,
|
||||
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
||||
|
||||
@@ -7,6 +7,7 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from flask import Flask, current_app, jsonify, render_template, request
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
@@ -1453,11 +1454,13 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
if not db:
|
||||
return jsonify({"error": "Database not enabled"}), 503
|
||||
|
||||
rf_config = app.config["RF_CONFIG"]
|
||||
peers = db.get_peers()
|
||||
peer_sync = app.config.get("PEER_SYNC")
|
||||
|
||||
return jsonify({
|
||||
"this_scanner": app.config["SCANNER_IDENTITY"],
|
||||
"is_master": rf_config.scanner.is_master,
|
||||
"peers": peers,
|
||||
"sync_status": peer_sync.get_status() if peer_sync else None
|
||||
})
|
||||
@@ -1603,6 +1606,71 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
"results": results
|
||||
})
|
||||
|
||||
# ==================== Multi-Node Master Dashboard Proxy API ====================
|
||||
|
||||
def proxy_peer_request(scanner_id: str, path: str, method: str = "GET", **kwargs):
|
||||
"""Proxy a request to a peer node.
|
||||
|
||||
Returns:
|
||||
- Response tuple (jsonify(...), status_code) on error or peer response
|
||||
- None if scanner_id matches local scanner (caller should use local handler)
|
||||
"""
|
||||
local_id = app.config.get("SCANNER_IDENTITY", {}).get("id")
|
||||
if scanner_id == local_id:
|
||||
return None # Signal to use local handler
|
||||
|
||||
db = app.config.get("DATABASE")
|
||||
peer = db.get_peer(scanner_id) if db else None
|
||||
if not peer or not peer.get("url"):
|
||||
return jsonify({"error": "Peer not found"}), 404
|
||||
|
||||
try:
|
||||
url = f"{peer['url']}{path}"
|
||||
resp = requests.request(method, url, timeout=15, **kwargs)
|
||||
data = resp.json()
|
||||
# Enrich response with source scanner info
|
||||
data["_source_scanner"] = scanner_id
|
||||
data["_source_position"] = {
|
||||
"lat": peer.get("latitude"),
|
||||
"lon": peer.get("longitude"),
|
||||
"floor": peer.get("floor")
|
||||
}
|
||||
return jsonify(data)
|
||||
except requests.RequestException as e:
|
||||
return jsonify({"error": f"Peer unreachable: {e}"}), 503
|
||||
|
||||
@app.route("/api/node/<scanner_id>/latest")
|
||||
def api_node_latest(scanner_id: str):
|
||||
"""Proxy: Get latest scan from a peer node."""
|
||||
result = proxy_peer_request(scanner_id, "/api/latest")
|
||||
if result is None:
|
||||
return api_latest_scan()
|
||||
return result
|
||||
|
||||
@app.route("/api/node/<scanner_id>/scan/bt", methods=["POST"])
|
||||
def api_node_scan_bt(scanner_id: str):
|
||||
"""Proxy: Trigger BT scan on a peer node."""
|
||||
result = proxy_peer_request(scanner_id, "/api/scan/bt", method="POST")
|
||||
if result is None:
|
||||
return api_scan_bt()
|
||||
return result
|
||||
|
||||
@app.route("/api/node/<scanner_id>/device/floors")
|
||||
def api_node_device_floors(scanner_id: str):
|
||||
"""Proxy: Get device floors from a peer node."""
|
||||
result = proxy_peer_request(scanner_id, "/api/device/floors")
|
||||
if result is None:
|
||||
return api_device_floors()
|
||||
return result
|
||||
|
||||
@app.route("/api/node/<scanner_id>/positions/trilaterated")
|
||||
def api_node_trilaterated(scanner_id: str):
|
||||
"""Proxy: Get trilaterated positions from a peer node."""
|
||||
result = proxy_peer_request(scanner_id, "/api/positions/trilaterated")
|
||||
if result is None:
|
||||
return api_trilaterated_positions()
|
||||
return result
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,44 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Node selector for master dashboard */
|
||||
#node-selector-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#node-selector-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#node-select {
|
||||
background: var(--bg-primary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
#node-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
#node-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.node-status {
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
@@ -31,6 +31,11 @@ let trilaterationEnabled = true;
|
||||
// Heat map state
|
||||
let heatMapEnabled = false;
|
||||
|
||||
// Multi-node master state
|
||||
let isMasterNode = false;
|
||||
let activeNode = 'local'; // 'local' or scanner_id
|
||||
let activeNodeInfo = null; // { id, name, url, lat, lon, floor }
|
||||
|
||||
// Auto-scan state
|
||||
let autoScanEnabled = false;
|
||||
let autoScanPollInterval = null;
|
||||
@@ -132,6 +137,10 @@ let openPopupDeviceId = null; // Track which device popup is open
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Store original position for node switching
|
||||
APP_CONFIG.originalLat = APP_CONFIG.defaultLat;
|
||||
APP_CONFIG.originalLon = APP_CONFIG.defaultLon;
|
||||
|
||||
initMap();
|
||||
initRadar();
|
||||
initFloorSelector();
|
||||
@@ -149,6 +158,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize WebSocket connection
|
||||
initWebSocket();
|
||||
|
||||
// Initialize master dashboard (checks if this node is master)
|
||||
initMasterDashboard();
|
||||
|
||||
// Start BT live tracking by default after a short delay
|
||||
setTimeout(() => {
|
||||
startLiveTracking();
|
||||
@@ -198,6 +210,176 @@ function initWebSocket() {
|
||||
rfMapperWS.on('scanUpdate', (data) => {
|
||||
handleWebSocketScanUpdate(data);
|
||||
});
|
||||
|
||||
// Handle peer scan updates (from master dashboard peer connections)
|
||||
rfMapperWS.on('peerScanUpdate', (data) => {
|
||||
if (activeNode !== 'local') {
|
||||
handleWebSocketScanUpdate(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Multi-Node Master Dashboard ==========
|
||||
|
||||
// Initialize master dashboard (checks if this node is master and shows node selector)
|
||||
async function initMasterDashboard() {
|
||||
try {
|
||||
const resp = await fetch('/api/peers');
|
||||
const data = await resp.json();
|
||||
|
||||
isMasterNode = data.is_master === true;
|
||||
|
||||
if (isMasterNode && data.peers?.length > 0) {
|
||||
document.getElementById('node-selector-container').classList.remove('hidden');
|
||||
populateNodeSelector(data.this_scanner, data.peers);
|
||||
console.log('[Master] Dashboard enabled with', data.peers.length, 'peers');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Master] Init failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate node selector dropdown with local and peer scanners
|
||||
function populateNodeSelector(thisScanner, peers) {
|
||||
const select = document.getElementById('node-select');
|
||||
select.innerHTML = `<option value="local">📍 ${thisScanner.name || thisScanner.id}</option>`;
|
||||
|
||||
peers.forEach(peer => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = peer.scanner_id;
|
||||
opt.textContent = `📡 ${peer.name || peer.scanner_id}`;
|
||||
opt.dataset.url = peer.url;
|
||||
opt.dataset.lat = peer.latitude;
|
||||
opt.dataset.lon = peer.longitude;
|
||||
opt.dataset.floor = peer.floor || 0;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Switch to viewing a different node's data
|
||||
async function switchNode(nodeId) {
|
||||
const select = document.getElementById('node-select');
|
||||
const statusEl = document.getElementById('node-status');
|
||||
const wasLiveTracking = liveTrackingEnabled;
|
||||
|
||||
// Stop current tracking and peer connection
|
||||
if (wasLiveTracking) stopLiveTracking();
|
||||
if (activeNode !== 'local' && typeof rfMapperWS !== 'undefined') {
|
||||
rfMapperWS.disconnectFromPeer();
|
||||
}
|
||||
|
||||
activeNode = nodeId;
|
||||
statusEl.textContent = '⏳';
|
||||
statusEl.style.color = '#fbbf24';
|
||||
|
||||
if (nodeId === 'local') {
|
||||
activeNodeInfo = null;
|
||||
// Restore local position
|
||||
const localLat = APP_CONFIG.originalLat || APP_CONFIG.defaultLat;
|
||||
const localLon = APP_CONFIG.originalLon || APP_CONFIG.defaultLon;
|
||||
updateMapCenter(localLat, localLon);
|
||||
|
||||
await loadLatestScan();
|
||||
await loadDevicePositions();
|
||||
await loadTrilateratedPositions();
|
||||
|
||||
// Reconnect local WebSocket
|
||||
if (typeof rfMapperWS !== 'undefined') {
|
||||
rfMapperWS.connect();
|
||||
}
|
||||
|
||||
statusEl.textContent = '●';
|
||||
statusEl.style.color = '#4ade80';
|
||||
console.log('[Node] Switched to local');
|
||||
} else {
|
||||
const opt = select.options[select.selectedIndex];
|
||||
activeNodeInfo = {
|
||||
id: nodeId,
|
||||
name: opt.textContent,
|
||||
url: opt.dataset.url,
|
||||
lat: parseFloat(opt.dataset.lat),
|
||||
lon: parseFloat(opt.dataset.lon),
|
||||
floor: parseInt(opt.dataset.floor) || 0
|
||||
};
|
||||
|
||||
// Center map on peer's position
|
||||
updateMapCenter(activeNodeInfo.lat, activeNodeInfo.lon);
|
||||
|
||||
try {
|
||||
// Load peer's data via proxy endpoints
|
||||
const [latestResp, floorsResp] = await Promise.all([
|
||||
fetch(`/api/node/${nodeId}/latest`),
|
||||
fetch(`/api/node/${nodeId}/device/floors`)
|
||||
]);
|
||||
|
||||
if (latestResp.ok) {
|
||||
scanData = await latestResp.json();
|
||||
if (floorsResp.ok) {
|
||||
const floorsData = await floorsResp.json();
|
||||
updateDeviceFloors(floorsData);
|
||||
}
|
||||
updateUI();
|
||||
|
||||
// Connect to peer's WebSocket for real-time updates
|
||||
if (typeof rfMapperWS !== 'undefined' && activeNodeInfo.url) {
|
||||
rfMapperWS.connectToPeer(activeNodeInfo.url);
|
||||
}
|
||||
|
||||
statusEl.textContent = '●';
|
||||
statusEl.style.color = '#4ade80';
|
||||
console.log('[Node] Switched to', nodeId);
|
||||
} else {
|
||||
throw new Error('Failed to load peer data');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Node] Load failed:`, e);
|
||||
statusEl.textContent = '○';
|
||||
statusEl.style.color = '#ef4444';
|
||||
}
|
||||
}
|
||||
|
||||
if (wasLiveTracking) startLiveTracking();
|
||||
}
|
||||
|
||||
// Update device floors data from API response
|
||||
function updateDeviceFloors(floorsData) {
|
||||
if (floorsData.floors) {
|
||||
// Update scanData devices with floor info
|
||||
if (scanData) {
|
||||
const floors = floorsData.floors;
|
||||
(scanData.wifi_networks || []).forEach(net => {
|
||||
if (floors[net.bssid] !== undefined) {
|
||||
net.floor = floors[net.bssid];
|
||||
}
|
||||
});
|
||||
(scanData.bluetooth_devices || []).forEach(dev => {
|
||||
if (floors[dev.address] !== undefined) {
|
||||
dev.floor = floors[dev.address];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (floorsData.positions) {
|
||||
manualPositions = floorsData.positions;
|
||||
}
|
||||
if (floorsData.sources) {
|
||||
deviceSources = floorsData.sources;
|
||||
}
|
||||
}
|
||||
|
||||
// Update map center position (for node switching)
|
||||
function updateMapCenter(lat, lon) {
|
||||
APP_CONFIG.defaultLat = lat;
|
||||
APP_CONFIG.defaultLon = lon;
|
||||
document.getElementById('lat-input').value = lat.toFixed(6);
|
||||
document.getElementById('lon-input').value = lon.toFixed(6);
|
||||
|
||||
if (map) {
|
||||
map.setView([lat, lon], map.getZoom());
|
||||
}
|
||||
if (map3d) {
|
||||
map3d.flyTo({ center: [lon, lat], duration: 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle scan updates received via WebSocket
|
||||
@@ -2186,7 +2368,12 @@ async function performLiveBTScan() {
|
||||
if (!liveTrackingEnabled) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scan/bt', {
|
||||
// Use node-specific endpoint if viewing a peer
|
||||
const endpoint = activeNode === 'local'
|
||||
? '/api/scan/bt'
|
||||
: `/api/node/${activeNode}/scan/bt`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
class RFMapperWS {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.peerSocket = null; // For peer node connections (master dashboard)
|
||||
this.connected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.listeners = {
|
||||
scanUpdate: [],
|
||||
connected: [],
|
||||
disconnected: []
|
||||
disconnected: [],
|
||||
peerConnected: [],
|
||||
peerDisconnected: [],
|
||||
peerScanUpdate: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +82,59 @@ class RFMapperWS {
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to a peer node's WebSocket (for master dashboard)
|
||||
connectToPeer(peerUrl) {
|
||||
this.disconnectFromPeer();
|
||||
|
||||
// Check if socket.io is loaded
|
||||
if (typeof io === 'undefined') {
|
||||
console.warn('[WS] socket.io not loaded, cannot connect to peer');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.peerSocket = io(peerUrl + '/ws/scan', {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000
|
||||
});
|
||||
|
||||
this.peerSocket.on('connect', () => {
|
||||
console.log('[WS] Connected to peer:', peerUrl);
|
||||
this._emit('peerConnected', peerUrl);
|
||||
});
|
||||
|
||||
this.peerSocket.on('scan_update', (data) => {
|
||||
this._emit('peerScanUpdate', data);
|
||||
});
|
||||
|
||||
this.peerSocket.on('disconnect', (reason) => {
|
||||
console.log('[WS] Peer disconnected:', reason);
|
||||
this._emit('peerDisconnected', { url: peerUrl, reason });
|
||||
});
|
||||
|
||||
this.peerSocket.on('connect_error', (error) => {
|
||||
console.warn('[WS] Peer connection error:', error.message);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[WS] Failed to connect to peer:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from peer node
|
||||
disconnectFromPeer() {
|
||||
if (this.peerSocket) {
|
||||
this.peerSocket.disconnect();
|
||||
this.peerSocket = null;
|
||||
console.log('[WS] Disconnected from peer');
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].push(callback);
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
||||
|
||||
{% block header_controls %}
|
||||
<div id="node-selector-container" class="hidden">
|
||||
<select id="node-select" onchange="switchNode(this.value)">
|
||||
<option value="local" selected>📍 This Scanner</option>
|
||||
</select>
|
||||
<span id="node-status" class="node-status">●</span>
|
||||
</div>
|
||||
<span id="scan-status" class="scan-info">Ready</span>
|
||||
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
||||
🔍 New Scan
|
||||
|
||||
Reference in New Issue
Block a user