- Added dashboard/static/js/fleet.js: core fleet management module with API functions for node operations (identify, reboot, update, remove), bulk operations (update all, re-baseline all), role assignment, config export/import, and camera fly-to integration - Added dashboard/static/css/fleet-page.css: complete styling for fleet table with sortable columns, status indicators, action buttons, bulk actions bar, filters, modals, and responsive layout - Fleet table columns: Name, MAC, Status, Firmware, Uptime, Position, Role, Health, Packet Rate, Temperature, Actions - Bulk actions: Update All (rolling OTA with 30s stagger), Re-baseline All, Export Config, Import Config - Camera fly-to: clicking position coordinates stores MAC in localStorage and navigates to live view with highlight - All node actions execute correctly: identify (blink LED), update firmware, remove from fleet, re-assign role - CSV export generates downloadable report with all node metrics Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
522 lines
16 KiB
JavaScript
522 lines
16 KiB
JavaScript
/**
|
|
* Spaxel Fleet Status Module
|
|
*
|
|
* Core fleet management functionality for:
|
|
* - Fleet data fetching and state management
|
|
* - Node operations (identify, reboot, update, remove)
|
|
* - Bulk operations (update all, re-baseline all)
|
|
* - Role assignment
|
|
* - Config export/import
|
|
* - Camera fly-to integration
|
|
*
|
|
* This module can be used by both the fleet page and fleet panel.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ============================================
|
|
// Constants
|
|
// ============================================
|
|
const CONFIG = {
|
|
pollIntervalMs: 10000, // Poll every 10 seconds
|
|
staleThresholdMs: 30000, // Node considered stale after 30s
|
|
otaStaggerMs: 30000, // 30 second stagger between OTA updates
|
|
apiBase: '/api'
|
|
};
|
|
|
|
const NODE_STATUS = {
|
|
ONLINE: 'online',
|
|
OFFLINE: 'offline',
|
|
STALE: 'stale',
|
|
UPDATING: 'updating',
|
|
UNPAIRED: 'unpaired'
|
|
};
|
|
|
|
const VALID_ROLES = ['tx', 'rx', 'tx_rx', 'passive', 'idle'];
|
|
|
|
// ============================================
|
|
// State
|
|
// ============================================
|
|
const state = {
|
|
nodes: [],
|
|
selectedNodes: new Set(),
|
|
latestFirmware: null,
|
|
filters: {
|
|
search: '',
|
|
status: '',
|
|
firmware: '',
|
|
roles: []
|
|
},
|
|
sortColumn: null,
|
|
sortDirection: 'asc'
|
|
};
|
|
|
|
// ============================================
|
|
// API Functions
|
|
// ============================================
|
|
async function fetchFleet() {
|
|
const response = await fetch(`${CONFIG.apiBase}/fleet`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function fetchFirmwareList() {
|
|
const response = await fetch(`${CONFIG.apiBase}/firmware`);
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function updateNodeLabel(mac, label) {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/${mac}/label`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ label })
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update label: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function setNodeRole(mac, role) {
|
|
if (!VALID_ROLES.includes(role)) {
|
|
throw new Error(`Invalid role: ${role}`);
|
|
}
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/${mac}/role`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role })
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to set role: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function identifyNode(mac, durationMs = 5000) {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/${mac}/identify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ duration_ms: durationMs })
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to identify node: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function updateNodeFirmware(mac) {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/${mac}/update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to start OTA: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function updateAllFirmware() {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/update-all`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update all: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function removeNode(mac) {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/${mac}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to remove node: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function rebaselineNode(mac) {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/${mac}/rebaseline`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to re-baseline: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function rebaselineAll() {
|
|
const response = await fetch(`${CONFIG.apiBase}/nodes/rebaseline-all`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to re-baseline all: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function exportConfig() {
|
|
const response = await fetch(`${CONFIG.apiBase}/export`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to export: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function importConfig(configData) {
|
|
const response = await fetch(`${CONFIG.apiBase}/import`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(configData)
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to import: HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
// ============================================
|
|
// Helper Functions
|
|
// ============================================
|
|
function getNodeStatus(node) {
|
|
if (node.unpaired) {
|
|
return NODE_STATUS.UNPAIRED;
|
|
}
|
|
if (node.ota_in_progress) {
|
|
return NODE_STATUS.UPDATING;
|
|
}
|
|
if (node.last_seen_ms) {
|
|
const lastSeen = new Date(node.last_seen_ms);
|
|
const now = new Date();
|
|
const diff = now - lastSeen;
|
|
if (diff < CONFIG.staleThresholdMs) {
|
|
return NODE_STATUS.ONLINE;
|
|
}
|
|
}
|
|
return NODE_STATUS.OFFLINE;
|
|
}
|
|
|
|
function isFirmwareOutdated(node) {
|
|
if (!state.latestFirmware || !node.firmware_version) {
|
|
return false;
|
|
}
|
|
return node.firmware_version !== state.latestFirmware;
|
|
}
|
|
|
|
function formatMAC(mac) {
|
|
const parts = mac.split(':');
|
|
if (parts.length === 6) {
|
|
return parts.slice(0, 4).join(':');
|
|
}
|
|
return mac;
|
|
}
|
|
|
|
function truncateMAC(mac) {
|
|
const parts = mac.split(':');
|
|
if (parts.length === 6) {
|
|
return parts.slice(0, 3).join(':') + '...';
|
|
}
|
|
return mac;
|
|
}
|
|
|
|
function formatRole(role) {
|
|
const roleMap = {
|
|
'tx': 'TX',
|
|
'rx': 'RX',
|
|
'tx_rx': 'TX-RX',
|
|
'passive': 'Passive',
|
|
'idle': 'Idle'
|
|
};
|
|
return roleMap[role] || role;
|
|
}
|
|
|
|
function formatUptime(seconds) {
|
|
if (!seconds) return '--';
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const 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(node) {
|
|
if (node.pos_x !== undefined && node.pos_y !== undefined && node.pos_z !== undefined) {
|
|
return `(${node.pos_x.toFixed(1)}, ${node.pos_y.toFixed(1)}, ${node.pos_z.toFixed(1)})`;
|
|
}
|
|
return '--';
|
|
}
|
|
|
|
function getHealthClass(score) {
|
|
if (score >= 0.7) return 'good';
|
|
if (score >= 0.4) return 'fair';
|
|
return 'poor';
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ============================================
|
|
// Camera Fly-To Integration
|
|
// ============================================
|
|
function flyToNode(mac) {
|
|
// Store target MAC in localStorage for live view
|
|
localStorage.setItem('fleetFlyToMAC', mac);
|
|
// If on fleet page, navigate to live view
|
|
if (window.location.pathname === '/fleet' || window.location.pathname.endsWith('/fleet.html')) {
|
|
window.location.href = '/?highlight=' + mac;
|
|
} else {
|
|
// Trigger custom event for live view to handle
|
|
window.dispatchEvent(new CustomEvent('fleet-flyto-node', {
|
|
detail: { mac }
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Filtering and Sorting
|
|
// ============================================
|
|
function applyFilters(nodes) {
|
|
let filtered = nodes.slice();
|
|
|
|
// Search filter
|
|
if (state.filters.search) {
|
|
const search = state.filters.search.toLowerCase();
|
|
filtered = filtered.filter(node => {
|
|
const label = node.name || node.label || '';
|
|
const mac = node.mac.toLowerCase();
|
|
return label.toLowerCase().includes(search) || mac.includes(search);
|
|
});
|
|
}
|
|
|
|
// Status filter
|
|
if (state.filters.status) {
|
|
filtered = filtered.filter(node => {
|
|
return getNodeStatus(node) === state.filters.status;
|
|
});
|
|
}
|
|
|
|
// Firmware filter
|
|
if (state.filters.firmware === 'outdated') {
|
|
filtered = filtered.filter(node => {
|
|
return isFirmwareOutdated(node);
|
|
});
|
|
}
|
|
|
|
// Role filter
|
|
if (state.filters.roles.length > 0) {
|
|
filtered = filtered.filter(node => {
|
|
return state.filters.roles.includes(node.role);
|
|
});
|
|
}
|
|
|
|
// Apply sorting
|
|
if (state.sortColumn) {
|
|
filtered.sort((a, b) => {
|
|
const aVal = getSortValue(a, state.sortColumn);
|
|
const bVal = getSortValue(b, state.sortColumn);
|
|
|
|
let comparison = 0;
|
|
if (typeof aVal === 'string') {
|
|
comparison = aVal.localeCompare(bVal);
|
|
} else {
|
|
comparison = aVal - bVal;
|
|
}
|
|
|
|
return state.sortDirection === 'asc' ? comparison : -comparison;
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
function getSortValue(node, column) {
|
|
switch (column) {
|
|
case 'label':
|
|
return node.name || node.label || '';
|
|
case 'mac':
|
|
return node.mac;
|
|
case 'status':
|
|
const status = getNodeStatus(node);
|
|
return status === 'online' ? 2 : status === 'updating' ? 1 : 0;
|
|
case 'firmware':
|
|
return node.firmware_version || '';
|
|
case 'uptime':
|
|
return node.uptime_seconds || 0;
|
|
case 'role':
|
|
return node.role;
|
|
case 'health':
|
|
return node.health_score || 0;
|
|
case 'position':
|
|
return (node.pos_x || 0) + (node.pos_y || 0) + (node.pos_z || 0);
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Bulk Operations
|
|
// ============================================
|
|
async function performBulkOTA(macs) {
|
|
const results = [];
|
|
for (let i = 0; i < macs.length; i++) {
|
|
const mac = macs[i];
|
|
try {
|
|
await updateNodeFirmware(mac);
|
|
results.push({ mac, success: true });
|
|
} catch (error) {
|
|
results.push({ mac, success: false, error: error.message });
|
|
}
|
|
// Stagger updates (except last one)
|
|
if (i < macs.length - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, CONFIG.otaStaggerMs));
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function performBulkRoleChange(macs, newRole) {
|
|
const results = [];
|
|
for (const mac of macs) {
|
|
try {
|
|
await setNodeRole(mac, newRole);
|
|
results.push({ mac, success: true });
|
|
} catch (error) {
|
|
results.push({ mac, success: false, error: error.message });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function performBulkRemoval(macs) {
|
|
const results = [];
|
|
for (const mac of macs) {
|
|
try {
|
|
await removeNode(mac);
|
|
results.push({ mac, success: true });
|
|
} catch (error) {
|
|
results.push({ mac, success: false, error: error.message });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// ============================================
|
|
// CSV Export
|
|
// ============================================
|
|
function downloadCSV(nodes) {
|
|
const headers = [
|
|
'MAC',
|
|
'Label',
|
|
'Status',
|
|
'Firmware Version',
|
|
'Uptime (s)',
|
|
'Role',
|
|
'Position (x,y,z)',
|
|
'Health Score',
|
|
'Packet Rate (Hz)',
|
|
'Temperature (C)',
|
|
'Last Seen'
|
|
];
|
|
|
|
const rows = nodes.map(node => [
|
|
node.mac,
|
|
node.name || node.label || '',
|
|
getNodeStatus(node),
|
|
node.firmware_version || '',
|
|
node.uptime_seconds || 0,
|
|
node.role,
|
|
formatPosition(node),
|
|
(node.health_score || 0).toFixed(2),
|
|
node.packet_rate || 0,
|
|
node.temperature || '',
|
|
new Date(node.last_seen_ms || 0).toISOString()
|
|
]);
|
|
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...rows.map(row => row.map(v => `"${v}"`).join(','))
|
|
].join('\n');
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `spaxel-fleet-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ============================================
|
|
// Public API
|
|
// ============================================
|
|
window.SpaxelFleet = {
|
|
// State
|
|
getState: () => state,
|
|
|
|
// Constants
|
|
CONFIG,
|
|
NODE_STATUS,
|
|
VALID_ROLES,
|
|
|
|
// API Functions
|
|
fetchFleet,
|
|
fetchFirmwareList,
|
|
updateNodeLabel,
|
|
setNodeRole,
|
|
identifyNode,
|
|
updateNodeFirmware,
|
|
updateAllFirmware,
|
|
removeNode,
|
|
rebaselineNode,
|
|
rebaselineAll,
|
|
exportConfig,
|
|
importConfig,
|
|
|
|
// Helper Functions
|
|
getNodeStatus,
|
|
isFirmwareOutdated,
|
|
formatMAC,
|
|
truncateMAC,
|
|
formatRole,
|
|
formatUptime,
|
|
formatPosition,
|
|
getHealthClass,
|
|
escapeHtml,
|
|
|
|
// Filtering and Sorting
|
|
applyFilters,
|
|
getSortValue,
|
|
|
|
// Camera Fly-To
|
|
flyToNode,
|
|
|
|
// Bulk Operations
|
|
performBulkOTA,
|
|
performBulkRoleChange,
|
|
performBulkRemoval,
|
|
|
|
// CSV Export
|
|
downloadCSV
|
|
};
|
|
|
|
console.log('[SpaxelFleet] Fleet module loaded');
|
|
})();
|