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:
User
2026-02-01 11:47:28 +01:00
parent f04ce5aed3
commit 588102ddf4
7 changed files with 363 additions and 2 deletions

View File

@@ -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)
---

View File

@@ -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,

View File

@@ -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

View File

@@ -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);

View File

@@ -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' }
});

View File

@@ -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);

View File

@@ -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