/**
* 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
};
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() {
const sidebar = document.querySelector('.sidebar');
if (!sidebar) {
console.warn('[Fleet] Sidebar not found');
return;
}
// Check if panel already exists
if (document.getElementById('fleet-panel')) {
return;
}
const panel = document.createElement('div');
panel.id = 'fleet-panel';
panel.className = 'panel';
panel.innerHTML = `
Before (${(event.coverage_before * 100).toFixed(0)}% coverage)
After (${(event.coverage_after * 100).toFixed(0)}% coverage)
Blend:
`;
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 =
'