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

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