- Add Position column showing (x, y, z) coordinates for each node - Make position clickable to fly camera to node in 3D view - Add bulk actions: Restart Selected, Update All, Re-baseline All, Export, Import - Expose Viz3D.flyToNode() function for camera navigation - Include node position, firmware version, uptime, and last seen in API response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2070 lines
73 KiB
JavaScript
2070 lines
73 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<div class="panel-header">
|
|
<h3>Fleet Health</h3>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button id="fleet-full-view-btn" class="btn btn-sm" title="Open full table view">
|
|
<span class="icon">⛶</span> Full View
|
|
</button>
|
|
<button id="fleet-optimise-btn" class="btn btn-sm" title="Re-optimise roles now">
|
|
<span class="icon">↻</span> Optimise
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-content">
|
|
<div id="fleet-warning" class="fleet-warning hidden">
|
|
<span class="warning-icon">⚠</span>
|
|
<span id="fleet-warning-text"></span>
|
|
<button id="fleet-warning-dismiss" class="btn-dismiss">×</button>
|
|
</div>
|
|
|
|
<div class="fleet-stats">
|
|
<div class="stat-row">
|
|
<span class="stat-label">Coverage</span>
|
|
<span id="fleet-coverage" class="stat-value">--</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Mean GDOP</span>
|
|
<span id="fleet-mean-gdop" class="stat-value">--</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Nodes</span>
|
|
<span id="fleet-node-count" class="stat-value">--</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fleet-roles">
|
|
<h4>Role Assignments</h4>
|
|
<div id="fleet-role-list" class="fleet-role-list">
|
|
<div class="empty-state">No nodes</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fleet-history">
|
|
<h4>Re-optimisation History</h4>
|
|
<div id="fleet-history-list" class="fleet-history-list">
|
|
<div class="empty-state">No events</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fleet-simulate">
|
|
<h4>Simulate Node Removal</h4>
|
|
<select id="fleet-simulate-select" class="select-sm">
|
|
<option value="">Select a node...</option>
|
|
</select>
|
|
<div id="fleet-simulate-result" class="simulate-result hidden"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<div class="empty-state">No nodes</div>';
|
|
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 += '<div class="fleet-role-item">' +
|
|
'<span class="node-mac">' + formatMAC(mac) + '</span>' +
|
|
'<span>' +
|
|
'<span class="node-role ' + role + '">' + role + '</span>' +
|
|
'<span class="health-score">' + healthDisplay + '</span>' +
|
|
'</span>' +
|
|
(isOnline ? '<button class="fleet-identify-btn" onclick="FleetPanel.identifyNode(\'' + mac + '\')" title="Identify (blink LED)">⚡</button>' : '') +
|
|
'</div>';
|
|
});
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function updateHistoryUI() {
|
|
var container = document.getElementById('fleet-history-list');
|
|
if (!container) return;
|
|
|
|
if (state.history.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">No events</div>';
|
|
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 += '<div class="fleet-history-item" data-event-id="' + event.id + '">' +
|
|
'<span class="history-time">' + timeStr + '</span>' +
|
|
'<span class="history-trigger">' + (event.trigger_reason || 'unknown') + '</span>' +
|
|
'<span class="history-delta ' + deltaClass + '">' + deltaSign + (delta * 100).toFixed(0) + '%</span>' +
|
|
'</div>';
|
|
});
|
|
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 = '<option value="">Select a node...</option>';
|
|
|
|
state.nodes.forEach(function(node, mac) {
|
|
if (node.online) {
|
|
html += '<option value="' + mac + '">' + formatMAC(mac) + '</option>';
|
|
}
|
|
});
|
|
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 = `
|
|
<div class="gdop-compare-content">
|
|
<div class="gdop-compare-header">
|
|
<h3>Coverage Impact: ${event.trigger_reason || 'Re-optimisation'}</h3>
|
|
<button class="gdop-compare-close">×</button>
|
|
</div>
|
|
<div class="gdop-maps">
|
|
<div class="gdop-map-container">
|
|
<h4>Before (${(event.coverage_before * 100).toFixed(0)}% coverage)</h4>
|
|
<canvas id="gdop-before-canvas" class="gdop-map-canvas"></canvas>
|
|
</div>
|
|
<div class="gdop-map-container">
|
|
<h4>After (${(event.coverage_after * 100).toFixed(0)}% coverage)</h4>
|
|
<canvas id="gdop-after-canvas" class="gdop-map-canvas"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="gdop-slider-container">
|
|
<label>Blend: <input type="range" id="gdop-blend-slider" class="gdop-slider" min="0" max="100" value="50"></label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 =
|
|
'<strong>Coverage ' + direction + ':</strong> ' + deltaAbs + '%' +
|
|
'<br><small>New coverage: ' + ((data.coverage_after || 0) * 100).toFixed(0) + '%</small>';
|
|
})
|
|
.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 = `
|
|
<div class="fleet-table-container">
|
|
<div class="fleet-table-header">
|
|
<h2>Fleet Management</h2>
|
|
<div class="fleet-table-actions">
|
|
<button id="fleet-refresh-btn" class="fleet-btn fleet-btn-secondary">
|
|
<span class="icon">↻</span> Refresh
|
|
</button>
|
|
<button id="fleet-bulk-identify-btn" class="fleet-btn fleet-btn-action" disabled>
|
|
<span class="icon">⚡</span> Identify Selected
|
|
</button>
|
|
<button id="fleet-bulk-restart-btn" class="fleet-btn fleet-btn-action" disabled>
|
|
<span class="icon">↻</span> Restart Selected
|
|
</button>
|
|
<button id="fleet-update-all-btn" class="fleet-btn fleet-btn-action">
|
|
<span class="icon">↑</span> Update All
|
|
</button>
|
|
<button id="fleet-rebaseline-all-btn" class="fleet-btn fleet-btn-action">
|
|
<span class="icon">♻</span> Re-baseline All
|
|
</button>
|
|
<div class="fleet-actions-divider"></div>
|
|
<button id="fleet-export-btn" class="fleet-btn fleet-btn-secondary">
|
|
<span class="icon">↓</span> Export
|
|
</button>
|
|
<button id="fleet-import-btn" class="fleet-btn fleet-btn-secondary">
|
|
<span class="icon">↑</span> Import
|
|
</button>
|
|
<button id="fleet-close-table-btn" class="fleet-btn fleet-btn-secondary">
|
|
<span class="icon">×</span> Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fleet-table-toolbar">
|
|
<div class="fleet-selection-info">
|
|
<input type="checkbox" id="fleet-select-all" class="fleet-checkbox">
|
|
<label for="fleet-select-all">
|
|
<span id="fleet-selected-count">0</span> of <span id="fleet-total-count">0</span> nodes selected
|
|
</label>
|
|
</div>
|
|
<div class="fleet-filters">
|
|
<select id="fleet-filter-role" class="fleet-select">
|
|
<option value="">All Roles</option>
|
|
<option value="tx">TX Only</option>
|
|
<option value="rx">RX Only</option>
|
|
<option value="tx_rx">TX/RX</option>
|
|
<option value="passive">Passive</option>
|
|
</select>
|
|
<select id="fleet-filter-status" class="fleet-select">
|
|
<option value="">All Status</option>
|
|
<option value="online">Online</option>
|
|
<option value="offline">Offline</option>
|
|
</select>
|
|
<input type="text" id="fleet-search" class="fleet-search-input" placeholder="Search MAC or name...">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fleet-table-wrapper">
|
|
<table class="fleet-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="fleet-select-col">
|
|
<input type="checkbox" id="fleet-header-select-all" class="fleet-checkbox">
|
|
</th>
|
|
<th class="sortable" data-sort="mac">MAC Address</th>
|
|
<th class="sortable" data-sort="role">Role</th>
|
|
<th class="sortable" data-sort="status">Status</th>
|
|
<th class="sortable" data-sort="position">Position</th>
|
|
<th class="sortable" data-sort="health">Health</th>
|
|
<th class="sortable" data-sort="uptime">Uptime</th>
|
|
<th class="sortable" data-sort="fw">Firmware</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="fleet-table-body">
|
|
<tr>
|
|
<td colspan="9" class="fleet-empty-state">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="fleet-table-footer">
|
|
<div class="fleet-stats-summary">
|
|
<span>Total: <strong id="fleet-stat-total">0</strong></span>
|
|
<span>Online: <strong id="fleet-stat-online">0</strong></span>
|
|
<span>Coverage: <strong id="fleet-stat-coverage">--%</strong></span>
|
|
<span>Mean GDOP: <strong id="fleet-stat-gdop">--</strong></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<tr><td colspan="9" class="fleet-empty-state">No nodes match the current filters</td></tr>';
|
|
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 += '<tr class="fleet-row' + (isSelected ? ' selected' : '') + '" data-mac="' + node.mac + '">' +
|
|
'<td class="fleet-select-col">' +
|
|
'<input type="checkbox" class="fleet-checkbox fleet-row-checkbox" ' +
|
|
(isSelected ? 'checked ' : '') + 'data-mac="' + node.mac + '">' +
|
|
'</td>' +
|
|
'<td class="fleet-mac-col">' +
|
|
'<span class="node-mac-full">' + node.mac + '</span>' +
|
|
(node.name ? '<br><span class="node-name">' + node.name + '</span>' : '') +
|
|
'</td>' +
|
|
'<td><span class="node-role-badge ' + node.role + '">' + node.role + '</span></td>' +
|
|
'<td><span class="node-status-badge ' + statusClass + '">' + statusText + '</span></td>' +
|
|
'<td class="fleet-position-col">' +
|
|
'<span class="position-link" data-mac="' + node.mac + '" title="Click to fly to node">' +
|
|
formatPosition(node.pos_x, node.pos_y, node.pos_z) +
|
|
'</span>' +
|
|
'</td>' +
|
|
'<td>' +
|
|
'<div class="health-bar-wrapper">' +
|
|
'<div class="health-bar health-bar-' + healthClass + '" style="width: ' + healthPercent + '%"></div>' +
|
|
'<span class="health-text">' + healthPercent + '%</span>' +
|
|
'</div>' +
|
|
'</td>' +
|
|
'<td class="fleet-uptime-col">' + uptimeStr + '</td>' +
|
|
'<td class="fleet-fw-col">' + firmware + '</td>' +
|
|
'<td class="fleet-actions-col">' +
|
|
'<button class="fleet-action-btn" data-action="flyto" data-mac="' + node.mac + '" title="Fly camera to node">⛶</button>' +
|
|
'<button class="fleet-action-btn" data-action="identify" data-mac="' + node.mac + '" title="Identify (blink LED)">⚡</button>' +
|
|
'<button class="fleet-action-btn" data-action="diagnostics" data-mac="' + node.mac + '" title="View diagnostics">⚙</button>' +
|
|
'</td>' +
|
|
'</tr>';
|
|
});
|
|
|
|
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 = `
|
|
<div class="fleet-diagnostics-content">
|
|
<div class="fleet-diagnostics-header">
|
|
<h3>Node Diagnostics</h3>
|
|
<button class="fleet-close-modal">×</button>
|
|
</div>
|
|
<div class="fleet-diagnostics-body">
|
|
<div class="diagnostics-section">
|
|
<h4>Node Information</h4>
|
|
<table class="diagnostics-table">
|
|
<tr><td>MAC Address:</td><td>${node.mac}</td></tr>
|
|
<tr><td>Role:</td><td>${node.role}</td></tr>
|
|
<tr><td>Status:</td><td>${node.online ? 'Online' : 'Offline'}</td></tr>
|
|
<tr><td>Firmware:</td><td>${node.firmware_version || '--'}</td></tr>
|
|
<tr><td>Uptime:</td><td>${formatUptime(node.uptime_seconds || 0)}</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="diagnostics-section">
|
|
<h4>Health Metrics</h4>
|
|
<table class="diagnostics-table">
|
|
<tr><td>Health Score:</td><td>${((node.health_score || 0) * 100).toFixed(0)}%</td></tr>
|
|
<tr><td>Last Seen:</td><td>${new Date((node.last_seen_ms || 0)).toLocaleString()}</td></tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="fleet-diagnostics-actions">
|
|
<button class="fleet-btn fleet-btn-primary" onclick="FleetPanel.identifyNode('${mac}')">Identify Node</button>
|
|
<button class="fleet-btn fleet-btn-secondary" onclick="FleetPanel.flyToNode('${mac}')">Fly to Node</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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();
|
|
}
|
|
})();
|