/** * Spaxel Dashboard - Fleet Health Panel * * Displays fleet status, role assignments, and re-optimisation controls. * Shows coverage degradation warnings and before/after GDOP comparison. */ (function() { 'use strict'; // ============================================ // State // ============================================ const state = { nodes: new Map(), // MAC -> node info roles: new Map(), // MAC -> role history: [], // Recent optimisation events coverageScore: 0, meanGDOP: 0, warningMessage: null, isDegraded: false, selectedCompareEvent: null, wsConnected: false }; const CONFIG = { pollIntervalMs: 15000, // Poll /api/fleet/health every 15 seconds historyLimit: 5 }; // Full table view state let isFullTableView = false; let selectedNodes = new Set(); let sortColumn = null; let sortDirection = 'asc'; let pollTimer = null; // ============================================ // Initialization // ============================================ function init() { console.log('[Fleet] Initializing fleet health panel'); // Create panel if not exists createPanel(); // Start polling startPolling(); // Register WebSocket message handler if (window.SpaxelApp && window.SpaxelApp.registerMessageHandler) { SpaxelApp.registerMessageHandler(handleWSMessage); } console.log('[Fleet] Fleet health panel ready'); } function createPanel() { // Check if panel already exists if (document.getElementById('fleet-panel')) { return; } // Create the fleet panel container (fixed position panel) const panel = document.createElement('div'); panel.id = 'fleet-panel'; panel.className = 'fleet-health-panel'; panel.style.cssText = 'position: fixed; top: 60px; left: 20px; width: 280px; max-height: calc(100vh - 80px); background: rgba(0, 0, 0, 0.8); border-radius: 8px; padding: 12px; z-index: 100; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1);'; panel.innerHTML = `

Fleet Health

Coverage --
Mean GDOP --
Nodes --

Role Assignments

No nodes

Re-optimisation History

No events

Simulate Node Removal

`; document.body.appendChild(panel); // Add event handlers document.getElementById('fleet-optimise-btn').addEventListener('click', onOptimiseClick); document.getElementById('fleet-full-view-btn').addEventListener('click', toggleFullTableView); document.getElementById('fleet-warning-dismiss').addEventListener('click', dismissWarning); document.getElementById('fleet-simulate-select').addEventListener('change', onSimulateSelect); addStyles(); } function addStyles() { if (document.getElementById('fleet-styles')) return; const style = document.createElement('style'); style.id = 'fleet-styles'; style.textContent = ` #fleet-panel { margin-top: 1rem; } #fleet-panel .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } #fleet-panel .panel-header h3 { margin: 0; font-size: 0.9rem; color: #aaa; } #fleet-optimise-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: #333; border: 1px solid #555; color: #ddd; cursor: pointer; border-radius: 3px; } #fleet-optimise-btn:hover { background: #444; } #fleet-optimise-btn:disabled { opacity: 0.5; cursor: not-allowed; } .fleet-warning { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.4); border-radius: 4px; padding: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: flex-start; gap: 0.5rem; } .fleet-warning.hidden { display: none; } .fleet-warning .warning-icon { color: #ef4444; font-size: 1rem; } .fleet-warning #fleet-warning-text { flex: 1; font-size: 0.8rem; color: #fca5a5; } .fleet-warning .btn-dismiss { background: none; border: none; color: #888; cursor: pointer; font-size: 1rem; padding: 0; line-height: 1; } .fleet-stats { background: rgba(255,255,255,0.03); border-radius: 4px; padding: 0.5rem; margin-bottom: 0.75rem; } .stat-row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.8rem; } .stat-label { color: #888; } .stat-value { color: #ddd; font-weight: 500; } .stat-value.degraded { color: #ef4444; } .fleet-roles h4, .fleet-history h4, .fleet-simulate h4 { font-size: 0.75rem; color: #888; margin: 0.75rem 0 0.5rem 0; text-transform: uppercase; letter-spacing: 0.5px; } .fleet-role-list { max-height: 150px; overflow-y: auto; font-size: 0.75rem; } .fleet-role-item { display: flex; justify-content: space-between; align-items: center; padding: 0.35rem 0.5rem; background: rgba(255,255,255,0.02); border-radius: 3px; margin-bottom: 0.25rem; } .fleet-role-item .node-mac { color: #aaa; font-family: monospace; font-size: 0.7rem; } .fleet-role-item .node-role { font-size: 0.65rem; padding: 0.15rem 0.35rem; border-radius: 2px; text-transform: uppercase; font-weight: 600; } .node-role.tx { background: rgba(239, 68, 68, 0.3); color: #fca5a5; } .node-role.rx { background: rgba(59, 130, 246, 0.3); color: #93c5fd; } .node-role.tx_rx { background: rgba(168, 85, 247, 0.3); color: #d8b4fe; } .node-role.passive { background: rgba(107, 114, 128, 0.3); color: #d1d5db; } .node-role .health-score { font-size: 0.65rem; color: #666; margin-left: 0.5rem; } .fleet-identify-btn { background: rgba(255, 193, 7, 0.2); border: 1px solid rgba(255, 193, 7, 0.4); color: #ffc107; font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 3px; cursor: pointer; transition: all 0.2s; margin-left: 0.5rem; } .fleet-identify-btn:hover { background: rgba(255, 193, 7, 0.3); border-color: rgba(255, 193, 7, 0.6); } .fleet-history-list { max-height: 120px; overflow-y: auto; font-size: 0.7rem; } .fleet-history-item { padding: 0.35rem 0.5rem; background: rgba(255,255,255,0.02); border-radius: 3px; margin-bottom: 0.25rem; cursor: pointer; } .fleet-history-item:hover { background: rgba(255,255,255,0.05); } .history-time { color: #666; font-size: 0.65rem; } .history-trigger { color: #aaa; margin-left: 0.5rem; } .history-delta { float: right; font-weight: 500; } .history-delta.positive { color: #66bb6a; } .history-delta.negative { color: #ef4444; } .fleet-simulate select { width: 100%; padding: 0.35rem; background: #222; border: 1px solid #444; color: #ddd; border-radius: 3px; font-size: 0.75rem; } .simulate-result { margin-top: 0.5rem; padding: 0.5rem; background: rgba(255,255,255,0.03); border-radius: 3px; font-size: 0.75rem; } .simulate-result.hidden { display: none; } .simulate-result.degraded { border-left: 3px solid #ef4444; } .simulate-result.ok { border-left: 3px solid #66bb6a; } .gdop-compare-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 1000; display: flex; align-items: center; justify-content: center; } .gdop-compare-overlay.hidden { display: none; } .gdop-compare-content { background: #1a1a2e; padding: 1.5rem; border-radius: 8px; max-width: 800px; width: 90%; } .gdop-compare-header { display: flex; justify-content: space-between; margin-bottom: 1rem; } .gdop-compare-header h3 { margin: 0; color: #ddd; } .gdop-compare-close { background: none; border: none; color: #888; font-size: 1.5rem; cursor: pointer; } .gdop-maps { display: flex; gap: 1rem; } .gdop-map-container { flex: 1; } .gdop-map-container h4 { margin: 0 0 0.5rem 0; font-size: 0.85rem; color: #aaa; } .gdop-map-canvas { width: 100%; height: 200px; background: #111; border-radius: 4px; } .gdop-slider-container { margin-top: 1rem; text-align: center; } .gdop-slider { width: 100%; max-width: 300px; } .empty-state { color: #555; font-size: 0.75rem; text-align: center; padding: 0.5rem; } /* Full Table View Styles */ .fleet-table-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.85); z-index: 1000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); } .fleet-table-container { background: #1a1a2e; border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); width: 90vw; max-width: 1200px; max-height: 85vh; display: flex; flex-direction: column; border: 1px solid rgba(255, 255, 255, 0.1); } .fleet-table-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .fleet-table-header h2 { margin: 0; font-size: 20px; color: #eee; } .fleet-table-actions { display: flex; gap: 8px; } .fleet-btn { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: background 0.2s; display: flex; align-items: center; gap: 6px; } .fleet-btn .icon { font-size: 14px; } .fleet-btn-primary { background: #4fc3f7; color: #1a1a2e; } .fleet-btn-primary:hover { background: #29b6f6; } .fleet-btn-secondary { background: rgba(255, 255, 255, 0.1); color: #ccc; border: 1px solid rgba(255, 255, 255, 0.15); } .fleet-btn-secondary:hover { background: rgba(255, 255, 255, 0.15); } .fleet-btn-action { background: rgba(76, 175, 80, 0.2); color: #66bb6a; border: 1px solid rgba(76, 175, 80, 0.4); } .fleet-btn-action:hover:not(:disabled) { background: rgba(76, 175, 80, 0.3); } .fleet-btn:disabled { opacity: 0.5; cursor: not-allowed; } .fleet-actions-divider { width: 1px; height: 24px; background: rgba(255, 255, 255, 0.15); margin: 0 4px; } .fleet-table-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); flex-wrap: wrap; gap: 12px; } .fleet-selection-info { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; } .fleet-checkbox { width: 16px; height: 16px; accent-color: #4fc3f7; cursor: pointer; } .fleet-filters { display: flex; gap: 8px; flex-wrap: wrap; } .fleet-select { padding: 6px 10px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 4px; color: #ddd; font-size: 12px; cursor: pointer; } .fleet-select:focus { outline: none; border-color: #4fc3f7; } .fleet-search-input { padding: 6px 10px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 4px; color: #ddd; font-size: 12px; width: 180px; } .fleet-search-input:focus { outline: none; border-color: #4fc3f7; } .fleet-table-wrapper { flex: 1; overflow: auto; padding: 0; } .fleet-table { width: 100%; border-collapse: collapse; font-size: 13px; } .fleet-table thead { position: sticky; top: 0; background: #1e1e3a; z-index: 1; } .fleet-table th { padding: 12px 16px; text-align: left; font-weight: 600; color: #888; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); white-space: nowrap; } .fleet-table th.sortable { cursor: pointer; user-select: none; } .fleet-table th.sortable:hover { background: rgba(255, 255, 255, 0.05); } .fleet-table th.sort-asc::after { content: ' ▲'; color: #4fc3f7; } .fleet-table th.sort-desc::after { content: ' ▼'; color: #4fc3f7; } .fleet-table td { padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .fleet-table tbody tr { transition: background 0.2s; } .fleet-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); } .fleet-table tbody tr.selected { background: rgba(79, 195, 247, 0.15); } .fleet-select-col { width: 40px; text-align: center; } .fleet-mac-col { font-family: monospace; color: #4fc3f7; } .node-mac-full { font-size: 13px; } .node-name { font-size: 11px; color: #888; font-family: inherit; } .node-role-badge { padding: 3px 8px; border-radius: 3px; font-size: 10px; font-weight: 600; text-transform: uppercase; } .node-role-badge.tx { background: rgba(239, 68, 68, 0.3); color: #fca5a5; } .node-role-badge.rx { background: rgba(59, 130, 246, 0.3); color: #93c5fd; } .node-role-badge.tx_rx { background: rgba(168, 85, 247, 0.3); color: #d8b4fe; } .node-role-badge.passive { background: rgba(107, 114, 128, 0.3); color: #d1d5db; } .node-status-badge { padding: 3px 8px; border-radius: 3px; font-size: 11px; font-weight: 500; } .node-status-badge.online { background: rgba(76, 175, 80, 0.2); color: #66bb6a; } .node-status-badge.offline { background: rgba(244, 67, 54, 0.2); color: #e57373; } .health-bar-wrapper { display: flex; align-items: center; gap: 8px; min-width: 100px; } .health-bar { height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.1); overflow: hidden; flex: 1; } .health-bar-good { background: linear-gradient(90deg, #22c55e, #66bb6a); } .health-bar-fair { background: linear-gradient(90deg, #eab308, #f59e0b); } .health-bar-poor { background: linear-gradient(90deg, #ef4444, #dc2626); } .health-text { font-size: 11px; color: #ccc; min-width: 35px; text-align: right; } .fleet-uptime-col { font-family: monospace; color: #aaa; font-size: 12px; } .fleet-fw-col { font-family: monospace; color: #888; font-size: 11px; } .fleet-actions-col { white-space: nowrap; } .fleet-position-col { font-family: monospace; font-size: 12px; color: #aaa; } .position-link { color: #4fc3f7; cursor: pointer; text-decoration: none; transition: color 0.2s; } .position-link:hover { color: #29b6f6; text-decoration: underline; } .fleet-action-btn { background: none; border: none; color: #888; cursor: pointer; padding: 4px; font-size: 14px; transition: color 0.2s, background 0.2s; border-radius: 3px; } .fleet-action-btn:hover { color: #4fc3f7; background: rgba(79, 195, 247, 0.1); } .fleet-empty-state { text-align: center; padding: 40px 20px; color: #666; font-size: 14px; } .fleet-table-footer { padding: 16px 24px; background: rgba(0, 0, 0, 0.2); border-top: 1px solid rgba(255, 255, 255, 0.1); } .fleet-stats-summary { display: flex; gap: 24px; font-size: 13px; color: #888; } .fleet-stats-summary strong { color: #ddd; } /* Diagnostics Modal */ .fleet-diagnostics-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); z-index: 1100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); } .fleet-diagnostics-content { background: #1a1a2e; border-radius: 12px; width: 90%; max-width: 500px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } .fleet-diagnostics-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .fleet-diagnostics-header h3 { margin: 0; font-size: 16px; color: #eee; } .fleet-close-modal { background: none; border: none; color: #888; font-size: 24px; cursor: pointer; padding: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; } .fleet-close-modal:hover { background: rgba(255, 255, 255, 0.1); color: #ccc; } .fleet-diagnostics-body { padding: 20px; max-height: 60vh; overflow-y: auto; } .diagnostics-section { margin-bottom: 20px; } .diagnostics-section:last-child { margin-bottom: 0; } .diagnostics-section h4 { margin: 0 0 12px 0; font-size: 13px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; } .diagnostics-table { width: 100%; font-size: 13px; } .diagnostics-table td { padding: 6px 0; color: #ccc; } .diagnostics-table td:first-child { color: #888; font-weight: 500; padding-right: 16px; } .fleet-diagnostics-actions { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.2); } .fleet-diagnostics-actions .fleet-btn { flex: 1; justify-content: center; } `; document.head.appendChild(style); } // ============================================ // Polling // ============================================ function startPolling() { if (pollTimer) clearInterval(pollTimer); fetchFleetHealth(); fetchFleetHistory(); pollTimer = setInterval(function() { fetchFleetHealth(); fetchFleetHistory(); }, CONFIG.pollIntervalMs); } function fetchFleetHealth() { fetch('/api/fleet/health') .then(function(res) { return res.json(); }) .then(function(data) { handleFleetHealth(data); }) .catch(function(err) { console.error('[Fleet] Failed to fetch health:', err); }); } function fetchFleetHistory() { fetch('/api/fleet/history?limit=' + CONFIG.historyLimit) .then(function(res) { return res.json(); }) .then(function(data) { handleFleetHistory(data); }) .catch(function(err) { console.error('[Fleet] Failed to fetch history:', err); }); } // ============================================ // Data Handlers // ============================================ function handleFleetHealth(data) { state.coverageScore = data.coverage_score || 0; state.meanGDOP = data.mean_gdop || 0; state.isDegraded = data.is_degraded || false; // Update node list if (data.nodes) { state.nodes.clear(); state.roles.clear(); data.nodes.forEach(function(node) { state.nodes.set(node.mac, node); state.roles.set(node.mac, node.role); }); } updateUI(); updateSimulateSelect(); } function handleFleetHistory(data) { state.history = data || []; updateHistoryUI(); } function handleWSMessage(msg) { switch (msg.type) { case 'fleet_change': handleFleetChange(msg); break; case 'fleet_health': handleFleetHealth(msg); break; case 'fleet_history': handleFleetHistory(msg.history); break; } } function handleFleetChange(msg) { // Update state from real-time event if (msg.role_assignments) { state.roles.clear(); Object.entries(msg.role_assignments).forEach(function(entry) { state.roles.set(entry[0], entry[1]); }); } if (msg.coverage_after !== undefined) { state.coverageScore = msg.coverage_after; } if (msg.mean_gdop_after !== undefined) { state.meanGDOP = msg.mean_gdop_after; } state.isDegraded = msg.is_degradation || false; // Show warning if degraded if (msg.is_degradation && msg.warning_message) { showWarning(msg.warning_message); } // Show toast notification if (window.SpaxelApp && SpaxelApp.showToast) { var toastType = msg.is_degradation ? 'warning' : 'info'; SpaxelApp.showToast('Fleet re-optimised: ' + msg.trigger_reason, toastType); } updateUI(); // If we have GDOP comparison data, show comparison button if (msg.gdop_before && msg.gdop_after) { showComparisonButton(msg); } // Refresh history fetchFleetHistory(); } // ============================================ // UI Updates // ============================================ function updateUI() { // Update coverage score var coverageEl = document.getElementById('fleet-coverage'); if (coverageEl) { coverageEl.textContent = (state.coverageScore * 100).toFixed(0) + '%'; coverageEl.classList.toggle('degraded', state.isDegraded); } // Update mean GDOP var gdopEl = document.getElementById('fleet-mean-gdop'); if (gdopEl) { gdopEl.textContent = state.meanGDOP.toFixed(2); } // Update node count var countEl = document.getElementById('fleet-node-count'); if (countEl) { var onlineCount = 0; state.nodes.forEach(function(node) { if (node.online) onlineCount++; }); countEl.textContent = onlineCount + '/' + state.nodes.size; } // Update role list updateRoleList(); } function updateRoleList() { var container = document.getElementById('fleet-role-list'); if (!container) return; if (state.nodes.size === 0) { container.innerHTML = '
No nodes
'; return; } var html = ''; state.nodes.forEach(function(node, mac) { var role = state.roles.get(mac) || node.role || 'rx'; var healthScore = node.health_score || 0; var healthDisplay = healthScore > 0 ? (healthScore * 100).toFixed(0) + '%' : '--'; var isOnline = node.online || false; html += '
' + '' + formatMAC(mac) + '' + '' + '' + role + '' + '' + healthDisplay + '' + '' + (isOnline ? '' : '') + '
'; }); container.innerHTML = html; } function updateHistoryUI() { var container = document.getElementById('fleet-history-list'); if (!container) return; if (state.history.length === 0) { container.innerHTML = '
No events
'; return; } var html = ''; state.history.forEach(function(event) { var time = new Date(event.timestamp_ms); var timeStr = time.toLocaleTimeString(); var delta = event.coverage_delta || 0; var deltaClass = delta >= 0 ? 'positive' : 'negative'; var deltaSign = delta >= 0 ? '+' : ''; html += '
' + '' + timeStr + '' + '' + (event.trigger_reason || 'unknown') + '' + '' + deltaSign + (delta * 100).toFixed(0) + '%' + '
'; }); container.innerHTML = html; // Add click handlers for comparison view container.querySelectorAll('.fleet-history-item').forEach(function(el) { el.addEventListener('click', function() { var eventId = el.dataset.eventId; showEventComparison(eventId); }); }); } function updateSimulateSelect() { var select = document.getElementById('fleet-simulate-select'); if (!select) return; var currentValue = select.value; var html = ''; state.nodes.forEach(function(node, mac) { if (node.online) { html += ''; } }); select.innerHTML = html; select.value = currentValue; } function formatMAC(mac) { // Abbreviate MAC for display var parts = mac.split(':'); if (parts.length >= 6) { return parts.slice(3, 6).join(':'); } return mac; } // ============================================ // Warning Display // ============================================ function showWarning(message) { state.warningMessage = message; var warningEl = document.getElementById('fleet-warning'); var textEl = document.getElementById('fleet-warning-text'); if (warningEl && textEl) { textEl.textContent = message; warningEl.classList.remove('hidden'); } } function dismissWarning() { state.warningMessage = null; var warningEl = document.getElementById('fleet-warning'); if (warningEl) { warningEl.classList.add('hidden'); } } function showComparisonButton(event) { // Add a "View Impact" button next to the warning var warningEl = document.getElementById('fleet-warning'); if (!warningEl) return; var existingBtn = document.getElementById('fleet-compare-btn'); if (existingBtn) existingBtn.remove(); var btn = document.createElement('button'); btn.id = 'fleet-compare-btn'; btn.className = 'btn btn-sm'; btn.textContent = 'View Impact'; btn.style.cssText = 'margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.7rem; background: #444; border: 1px solid #666; color: #ddd; cursor: pointer; border-radius: 3px;'; btn.addEventListener('click', function() { showGDOPComparison(event); }); warningEl.appendChild(btn); } // ============================================ // GDOP Comparison View // ============================================ function showGDOPComparison(event) { // Remove existing overlay var existing = document.querySelector('.gdop-compare-overlay'); if (existing) existing.remove(); var overlay = document.createElement('div'); overlay.className = 'gdop-compare-overlay'; overlay.innerHTML = `

Coverage Impact: ${event.trigger_reason || 'Re-optimisation'}

Before (${(event.coverage_before * 100).toFixed(0)}% coverage)

After (${(event.coverage_after * 100).toFixed(0)}% coverage)

`; document.body.appendChild(overlay); // Close handlers overlay.querySelector('.gdop-compare-close').addEventListener('click', function() { overlay.remove(); }); overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); }); // Draw GDOP maps if (event.gdop_before && event.gdop_after) { drawGDOPMap('gdop-before-canvas', event.gdop_before, event.gdop_cols, event.gdop_rows); drawGDOPMap('gdop-after-canvas', event.gdop_after, event.gdop_cols, event.gdop_rows); } // Blend slider var slider = document.getElementById('gdop-blend-slider'); if (slider) { slider.addEventListener('input', function() { updateBlend(slider.value); }); } } function drawGDOPMap(canvasId, data, cols, rows) { var canvas = document.getElementById(canvasId); if (!canvas || !data || !cols || !rows) return; var ctx = canvas.getContext('2d'); var rect = canvas.getBoundingClientRect(); canvas.width = rect.width * window.devicePixelRatio; canvas.height = rect.height * window.devicePixelRatio; ctx.scale(window.devicePixelRatio, window.devicePixelRatio); var cellWidth = rect.width / cols; var cellHeight = rect.height / rows; for (var row = 0; row < rows; row++) { for (var col = 0; col < cols; col++) { var idx = row * cols + col; var gdop = data[idx] || 10; var color = gdopToColor(gdop); ctx.fillStyle = color; ctx.fillRect(col * cellWidth, row * cellHeight, cellWidth, cellHeight); } } } function gdopToColor(gdop) { // GDOP < 2 = green, 2-5 = yellow, > 5 = red var t = Math.min(1, Math.max(0, (gdop - 1) / 5)); if (gdop < 2) { // Green to yellow var g = 1; var r = t * 2; return 'rgb(' + Math.floor(r * 255) + ',' + Math.floor(g * 255) + ',0)'; } else { // Yellow to red var r = 1; var g = Math.max(0, 1 - (t - 0.4) * 1.5); return 'rgb(' + Math.floor(r * 255) + ',' + Math.floor(g * 200) + ',0)'; } } function updateBlend(value) { // Adjust opacity of the after map var afterCanvas = document.getElementById('gdop-after-canvas'); if (afterCanvas) { afterCanvas.style.opacity = value / 100; } } function showEventComparison(eventId) { // Find the event in history var event = state.history.find(function(e) { return String(e.id) === String(eventId); }); if (event && event.gdop_before && event.gdop_after) { showGDOPComparison(event); } } // ============================================ // Actions // ============================================ function onOptimiseClick() { var btn = document.getElementById('fleet-optimise-btn'); if (btn) btn.disabled = true; fetch('/api/fleet/optimise', { method: 'POST' }) .then(function(res) { return res.json(); }) .then(function(data) { if (btn) btn.disabled = false; if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Fleet optimised: ' + (data.trigger_reason || 'manual'), 'success'); } handleFleetHealth(data); }) .catch(function(err) { if (btn) btn.disabled = false; console.error('[Fleet] Optimise failed:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Optimisation failed', 'error'); } }); } function onSimulateSelect(e) { var mac = e.target.value; var resultEl = document.getElementById('fleet-simulate-result'); if (!mac) { if (resultEl) resultEl.classList.add('hidden'); return; } fetch('/api/fleet/simulate?mac=' + encodeURIComponent(mac)) .then(function(res) { return res.json(); }) .then(function(data) { if (!resultEl) return; resultEl.classList.remove('hidden'); var delta = data.coverage_delta || 0; var deltaAbs = Math.abs(delta * 100).toFixed(0); var direction = delta < 0 ? 'drop' : 'increase'; var className = delta < -0.1 ? 'degraded' : 'ok'; resultEl.className = 'simulate-result ' + className; resultEl.innerHTML = 'Coverage ' + direction + ': ' + deltaAbs + '%' + '
New coverage: ' + ((data.coverage_after || 0) * 100).toFixed(0) + '%'; }) .catch(function(err) { console.error('[Fleet] Simulate failed:', err); if (resultEl) { resultEl.classList.remove('hidden'); resultEl.className = 'simulate-result'; resultEl.textContent = 'Simulation failed'; } }); } // ============================================ // Full Table View // ============================================ function toggleFullTableView() { isFullTableView = !isFullTableView; if (isFullTableView) { showFullTableView(); } else { hideFullTableView(); } } function showFullTableView() { // Create overlay var overlay = document.createElement('div'); overlay.id = 'fleet-table-overlay'; overlay.className = 'fleet-table-overlay'; overlay.innerHTML = `

Fleet Management

MAC Address Role Status Position Health Uptime Firmware Actions
Loading...
`; document.body.appendChild(overlay); // Add event listeners document.getElementById('fleet-close-table-btn').addEventListener('click', hideFullTableView); document.getElementById('fleet-refresh-btn').addEventListener('click', refreshFullTable); document.getElementById('fleet-bulk-identify-btn').addEventListener('click', bulkIdentify); document.getElementById('fleet-bulk-restart-btn').addEventListener('click', bulkRestart); document.getElementById('fleet-update-all-btn').addEventListener('click', updateAllNodes); document.getElementById('fleet-rebaseline-all-btn').addEventListener('click', rebaselineAllNodes); document.getElementById('fleet-export-btn').addEventListener('click', exportConfig); document.getElementById('fleet-import-btn').addEventListener('click', importConfig); document.getElementById('fleet-select-all').addEventListener('change', toggleSelectAll); document.getElementById('fleet-header-select-all').addEventListener('change', toggleSelectAll); document.getElementById('fleet-filter-role').addEventListener('change', renderFullTable); document.getElementById('fleet-filter-status').addEventListener('change', renderFullTable); document.getElementById('fleet-search').addEventListener('input', renderFullTable); // Add sort handlers overlay.querySelectorAll('th.sortable').forEach(function(th) { th.addEventListener('click', function() { var column = th.dataset.sort; handleSort(column); }); }); // Populate and render renderFullTable(); } function hideFullTableView() { isFullTableView = false; var overlay = document.getElementById('fleet-table-overlay'); if (overlay) { overlay.remove(); } selectedNodes.clear(); } function renderFullTable() { var tbody = document.getElementById('fleet-table-body'); if (!tbody) return; var filterRole = document.getElementById('fleet-filter-role').value; var filterStatus = document.getElementById('fleet-filter-status').value; var searchTerm = document.getElementById('fleet-search').value.toLowerCase(); var nodes = Array.from(state.nodes.values()); // Apply filters nodes = nodes.filter(function(node) { if (filterRole && node.role !== filterRole) return false; if (filterStatus === 'online' && !node.online) return false; if (filterStatus === 'offline' && node.online) return false; if (searchTerm && !node.mac.toLowerCase().includes(searchTerm) && !(node.name && node.name.toLowerCase().includes(searchTerm))) { return false; } return true; }); // Apply sort if (sortColumn) { nodes.sort(function(a, b) { var aVal = getNodeSortValue(a, sortColumn); var bVal = getNodeSortValue(b, sortColumn); if (sortDirection === 'asc') { return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; } else { return aVal < bVal ? 1 : aVal > bVal ? -1 : 0; } }); } // Update stats var totalNodes = state.nodes.size; var onlineNodes = Array.from(state.nodes.values()).filter(function(n) { return n.online; }).length; document.getElementById('fleet-total-count').textContent = totalNodes; document.getElementById('fleet-selected-count').textContent = selectedNodes.size; document.getElementById('fleet-stat-total').textContent = totalNodes; document.getElementById('fleet-stat-online').textContent = onlineNodes; document.getElementById('fleet-stat-coverage').textContent = (state.coverageScore * 100).toFixed(0) + '%'; document.getElementById('fleet-stat-gdop').textContent = state.meanGDOP.toFixed(2); // Update bulk button state var bulkBtn = document.getElementById('fleet-bulk-identify-btn'); if (bulkBtn) { bulkBtn.disabled = selectedNodes.size === 0; } // Render rows if (nodes.length === 0) { tbody.innerHTML = 'No nodes match the current filters'; return; } var html = ''; nodes.forEach(function(node) { var isSelected = selectedNodes.has(node.mac); var healthScore = node.health_score || 0; var healthPercent = (healthScore * 100).toFixed(0); var healthClass = healthScore > 0.7 ? 'good' : healthScore > 0.4 ? 'fair' : 'poor'; var uptime = node.uptime_seconds || 0; var uptimeStr = formatUptime(uptime); var firmware = node.firmware_version || '--'; var statusClass = node.online ? 'online' : 'offline'; var statusText = node.online ? 'Online' : 'Offline'; html += '' + '' + '' + '' + '' + '' + node.mac + '' + (node.name ? '
' + node.name + '' : '') + '' + '' + node.role + '' + '' + statusText + '' + '' + '' + formatPosition(node.pos_x, node.pos_y, node.pos_z) + '' + '' + '' + '
' + '
' + '' + healthPercent + '%' + '
' + '' + '' + uptimeStr + '' + '' + firmware + '' + '' + '' + '' + '' + '' + ''; }); tbody.innerHTML = html; // Add row checkbox handlers tbody.querySelectorAll('.fleet-row-checkbox').forEach(function(checkbox) { checkbox.addEventListener('change', function() { var mac = this.dataset.mac; if (this.checked) { selectedNodes.add(mac); } else { selectedNodes.delete(mac); } renderFullTable(); // Re-render to update selection state }); }); // Add action button handlers tbody.querySelectorAll('.fleet-action-btn').forEach(function(btn) { btn.addEventListener('click', function() { var action = this.dataset.action; var mac = this.dataset.mac; handleNodeAction(action, mac); }); }); // Add position link click handlers tbody.querySelectorAll('.position-link').forEach(function(link) { link.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); var mac = this.dataset.mac; flyToNode(mac); }); }); // Add row click handler for selection tbody.querySelectorAll('.fleet-row').forEach(function(row) { row.addEventListener('dblclick', function() { var mac = row.dataset.mac; handleNodeAction('flyto', mac); }); }); } function getNodeSortValue(node, column) { switch (column) { case 'mac': return node.mac || ''; case 'role': return node.role || ''; case 'status': return node.online ? 1 : 0; case 'position': return (node.pos_x || 0) + (node.pos_y || 0) + (node.pos_z || 0); case 'health': return node.health_score || 0; case 'uptime': return node.uptime_seconds || 0; case 'fw': return node.firmware_version || ''; default: return ''; } } function handleSort(column) { if (sortColumn === column) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { sortColumn = column; sortDirection = 'asc'; } // Update sort indicators document.querySelectorAll('th.sortable').forEach(function(th) { th.classList.remove('sort-asc', 'sort-desc'); if (th.dataset.sort === column) { th.classList.add('sort-' + sortDirection); } }); renderFullTable(); } function toggleSelectAll(e) { var isChecked = e.target.checked; var filterRole = document.getElementById('fleet-filter-role').value; var filterStatus = document.getElementById('fleet-filter-status').value; var searchTerm = document.getElementById('fleet-search').value.toLowerCase(); Array.from(state.nodes.values()).forEach(function(node) { // Check if node matches current filters if (filterRole && node.role !== filterRole) return; if (filterStatus === 'online' && !node.online) return; if (filterStatus === 'offline' && node.online) return; if (searchTerm && !node.mac.toLowerCase().includes(searchTerm) && !(node.name && node.name.toLowerCase().includes(searchTerm))) { return; } if (isChecked) { selectedNodes.add(node.mac); } else { selectedNodes.delete(node.mac); } }); renderFullTable(); } function handleNodeAction(action, mac) { switch (action) { case 'flyto': flyToNode(mac); break; case 'identify': identifyNode(mac); break; case 'diagnostics': showNodeDiagnostics(mac); break; } } function flyToNode(mac) { // Close the table view first hideFullTableView(); // Use Viz3D to fly camera to the node if (window.Viz3D && Viz3D.flyToNode) { Viz3D.flyToNode(mac); } else if (window.Viz3D && Viz3D.focusOnNode) { Viz3D.focusOnNode(mac); } else { console.warn('[Fleet] No flyToNode method available on Viz3D'); } } function showNodeDiagnostics(mac) { var node = state.nodes.get(mac); if (!node) return; // Create diagnostics modal var modal = document.createElement('div'); modal.className = 'fleet-diagnostics-modal'; modal.innerHTML = `

Node Diagnostics

Node Information

MAC Address:${node.mac}
Role:${node.role}
Status:${node.online ? 'Online' : 'Offline'}
Firmware:${node.firmware_version || '--'}
Uptime:${formatUptime(node.uptime_seconds || 0)}

Health Metrics

Health Score:${((node.health_score || 0) * 100).toFixed(0)}%
Last Seen:${new Date((node.last_seen_ms || 0)).toLocaleString()}
`; document.body.appendChild(modal); modal.querySelector('.fleet-close-modal').addEventListener('click', function() { modal.remove(); }); modal.addEventListener('click', function(e) { if (e.target === modal) { modal.remove(); } }); } function bulkIdentify() { if (selectedNodes.size === 0) return; selectedNodes.forEach(function(mac) { identifyNode(mac, 3000); // 3 second blink }); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Identifying ' + selectedNodes.size + ' nodes', 'info'); } } function refreshFullTable() { fetchFleetHealth(); fetchFleetHistory(); renderFullTable(); } function formatUptime(seconds) { if (!seconds) return '--'; var days = Math.floor(seconds / 86400); var hours = Math.floor((seconds % 86400) / 3600); var minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return days + 'd ' + hours + 'h'; } else if (hours > 0) { return hours + 'h ' + minutes + 'm'; } else { return minutes + 'm'; } } function formatPosition(x, y, z) { var px = (x || 0).toFixed(1); var py = (y || 0).toFixed(1); var pz = (z || 0).toFixed(1); return '(' + px + ', ' + py + ', ' + pz + ')'; } // ============================================ // Bulk Actions // ============================================ function bulkRestart() { if (selectedNodes.size === 0) return; selectedNodes.forEach(function(mac) { restartNode(mac); }); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Restarting ' + selectedNodes.size + ' nodes', 'info'); } } function restartNode(mac) { fetch('/api/nodes/' + mac + '/reboot', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(function(res) { if (!res.ok) { throw new Error('Restart failed: ' + res.status); } return res.json(); }) .then(function(data) { if (window.SpaxelApp && window.SpaxelApp.showToast) { window.SpaxelApp.showToast('Restart command sent to ' + mac, 'success'); } }) .catch(function(err) { console.error('[Fleet] Restart error:', err); if (window.SpaxelApp && window.SpaxelApp.showToast) { window.SpaxelApp.showToast('Failed to restart ' + mac + ': ' + err.message, 'error'); } }); } function updateAllNodes() { if (!confirm('Start OTA update for all nodes? This may take several minutes.')) { return; } fetch('/api/nodes/update-all', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(function(res) { if (!res.ok) { throw new Error('Update all failed: ' + res.status); } return res.json(); }) .then(function(data) { if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('OTA update started for ' + (data.count || 0) + ' nodes', 'success'); } }) .catch(function(err) { console.error('[Fleet] Update all error:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to start OTA update: ' + err.message, 'error'); } }); } function rebaselineAllNodes() { if (!confirm('Re-baseline all links? This requires an empty room for accurate results.')) { return; } fetch('/api/nodes/rebaseline-all', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(function(res) { if (!res.ok) { throw new Error('Re-baseline failed: ' + res.status); } return res.json(); }) .then(function(data) { if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Re-baseline started for ' + (data.count || 0) + ' nodes', 'success'); } }) .catch(function(err) { console.error('[Fleet] Re-baseline error:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to start re-baseline: ' + err.message, 'error'); } }); } function exportConfig() { fetch('/api/export') .then(function(res) { if (!res.ok) { throw new Error('Export failed: ' + res.status); } return res.json(); }) .then(function(data) { var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'spaxel-config-' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Configuration exported successfully', 'success'); } }) .catch(function(err) { console.error('[Fleet] Export error:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to export configuration: ' + err.message, 'error'); } }); } function importConfig() { var input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = function(e) { var file = e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(event) { try { var config = JSON.parse(event.target.result); if (!confirm('Import configuration? This will replace all existing nodes, zones, and settings.')) { return; } fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }) .then(function(res) { if (!res.ok) { throw new Error('Import failed: ' + res.status); } return res.json(); }) .then(function(data) { if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Configuration imported successfully. Reloading...', 'success'); } setTimeout(function() { location.reload(); }, 2000); }) .catch(function(err) { console.error('[Fleet] Import error:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to import configuration: ' + err.message, 'error'); } }); } catch (err) { if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Invalid JSON file: ' + err.message, 'error'); } } }; reader.readAsText(file); }; input.click(); } // ============================================ // Public API // ============================================ window.FleetPanel = { init: init, handleFleetChange: handleFleetChange, handleFleetHealth: handleFleetHealth, showWarning: showWarning, dismissWarning: dismissWarning, getState: function() { return state; }, identifyNode: identifyNode, flyToNode: flyToNode, toggleFullTableView: toggleFullTableView, showFullTableView: showFullTableView, hideFullTableView: hideFullTableView }; // ============================================ // Identify Node Action // ============================================ function identifyNode(mac, durationMs) { var payload = durationMs ? JSON.stringify({ duration_ms: durationMs }) : JSON.stringify({}); fetch('/api/nodes/' + mac + '/identify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }) .then(function(res) { if (!res.ok) { throw new Error('Identify failed: ' + res.status); } return res.json(); }) .then(function(data) { if (window.SpaxelApp && window.SpaxelApp.showToast) { window.SpaxelApp.showToast('Identify command sent to ' + mac, 'success'); } }) .catch(function(err) { console.error('[Fleet] Identify error:', err); if (window.SpaxelApp && window.SpaxelApp.showToast) { window.SpaxelApp.showToast('Failed to identify ' + mac + ': ' + err.message, 'error'); } }); } // Auto-init when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();