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:
@@ -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' }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user