Traefik forward-auth with Google OAuth already gates all non-device routes. The in-app PIN system was redundant. Removes auth middleware, /api/auth/* endpoints, auth.js from all HTML pages, and SpaxelAuth references from JS. The auth package remains for install_secret/node token derivation used by provisioning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1384 lines
47 KiB
JavaScript
1384 lines
47 KiB
JavaScript
/**
|
|
* Spaxel Fleet Status Page
|
|
*
|
|
* Provides a comprehensive table view of all nodes with:
|
|
* - Sorting and filtering
|
|
* - Bulk actions
|
|
* - Inline label editing
|
|
* - Camera fly-to integration
|
|
* - CSV export
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ============================================
|
|
// State
|
|
// ============================================
|
|
const state = {
|
|
nodes: [], // All nodes from API
|
|
filteredNodes: [], // Nodes after filtering
|
|
selectedNodes: new Set(), // Selected MAC addresses
|
|
sortColumn: null,
|
|
sortDirection: 'asc', // 'asc' or 'desc'
|
|
filters: {
|
|
search: '',
|
|
status: '',
|
|
firmware: '',
|
|
roles: []
|
|
},
|
|
latestFirmware: null, // Latest firmware version
|
|
wsConnected: false
|
|
};
|
|
|
|
const CONFIG = {
|
|
pollIntervalMs: 10000, // Poll every 10 seconds
|
|
staleThresholdMs: 30000 // Node considered stale after 30s
|
|
};
|
|
|
|
// ============================================
|
|
// DOM Elements
|
|
// ============================================
|
|
let elements = {};
|
|
|
|
// ============================================
|
|
// Initialization
|
|
// ============================================
|
|
async function init() {
|
|
console.log('[FleetPage] Initializing fleet status page');
|
|
|
|
cacheElements();
|
|
bindEvents();
|
|
await fetchFleetData();
|
|
startPolling();
|
|
|
|
console.log('[FleetPage] Fleet status page ready');
|
|
}
|
|
|
|
function cacheElements() {
|
|
elements = {
|
|
// Table elements
|
|
tableBody: document.getElementById('fleet-table-body'),
|
|
selectAllCheckbox: document.getElementById('select-all-checkbox'),
|
|
|
|
// Summary elements
|
|
totalSummary: document.getElementById('fleet-total'),
|
|
onlineSummary: document.getElementById('fleet-online'),
|
|
|
|
// Toolbar elements
|
|
toolbar: document.getElementById('fleet-toolbar'),
|
|
searchInput: document.getElementById('fleet-search'),
|
|
filterStatus: document.getElementById('filter-status'),
|
|
filterFirmware: document.getElementById('filter-firmware'),
|
|
filterRole: document.getElementById('filter-role'),
|
|
activeFilters: document.getElementById('active-filters'),
|
|
|
|
// Bulk actions
|
|
bulkActionsBar: document.getElementById('bulk-actions-bar'),
|
|
bulkSelectedCount: document.getElementById('bulk-selected-count'),
|
|
bulkOtaBtn: document.getElementById('bulk-ota-btn'),
|
|
bulkRoleBtn: document.getElementById('bulk-role-btn'),
|
|
bulkRemoveBtn: document.getElementById('bulk-remove-btn'),
|
|
bulkClearBtn: document.getElementById('bulk-clear-btn'),
|
|
|
|
// Header actions
|
|
refreshBtn: document.getElementById('fleet-refresh-btn'),
|
|
updateAllBtn: document.getElementById('fleet-update-all-btn'),
|
|
downloadBtn: document.getElementById('fleet-download-btn'),
|
|
|
|
// Empty state
|
|
emptyState: document.getElementById('empty-state'),
|
|
|
|
// Modals
|
|
otaModal: document.getElementById('ota-modal'),
|
|
otaConfirmBtn: document.getElementById('ota-confirm-btn'),
|
|
otaNodeLabel: document.getElementById('ota-node-label'),
|
|
otaCurrentVersion: document.getElementById('ota-current-version'),
|
|
otaLatestVersion: document.getElementById('ota-latest-version'),
|
|
otaInfoCurrent: document.getElementById('ota-info-current'),
|
|
otaInfoLatest: document.getElementById('ota-info-latest'),
|
|
otaInfoStatus: document.getElementById('ota-info-status'),
|
|
|
|
roleModal: document.getElementById('role-modal'),
|
|
roleConfirmBtn: document.getElementById('role-confirm-btn'),
|
|
roleNodeCount: document.getElementById('role-node-count'),
|
|
|
|
removeModal: document.getElementById('remove-modal'),
|
|
removeConfirmBtn: document.getElementById('remove-confirm-btn'),
|
|
removeNodeCount: document.getElementById('remove-node-count'),
|
|
removeNodeList: document.getElementById('remove-node-list'),
|
|
|
|
toastContainer: document.getElementById('toast-container')
|
|
};
|
|
}
|
|
|
|
function bindEvents() {
|
|
// Refresh button
|
|
elements.refreshBtn.addEventListener('click', () => {
|
|
fetchFleetData();
|
|
});
|
|
|
|
// Update all button
|
|
elements.updateAllBtn.addEventListener('click', () => {
|
|
updateAllFirmware();
|
|
});
|
|
|
|
// Download button
|
|
elements.downloadBtn.addEventListener('click', () => {
|
|
downloadCSV();
|
|
});
|
|
|
|
// Select all checkbox
|
|
elements.selectAllCheckbox.addEventListener('change', (e) => {
|
|
toggleSelectAll(e.target.checked);
|
|
});
|
|
|
|
// Filters
|
|
elements.searchInput.addEventListener('input', debounce((e) => {
|
|
state.filters.search = e.target.value.toLowerCase();
|
|
applyFilters();
|
|
}, 300));
|
|
|
|
elements.filterStatus.addEventListener('change', (e) => {
|
|
state.filters.status = e.target.value;
|
|
applyFilters();
|
|
});
|
|
|
|
elements.filterFirmware.addEventListener('change', (e) => {
|
|
state.filters.firmware = e.target.value;
|
|
applyFilters();
|
|
});
|
|
|
|
elements.filterRole.addEventListener('change', (e) => {
|
|
const selected = Array.from(e.target.selectedOptions).map(opt => opt.value);
|
|
state.filters.roles = selected;
|
|
applyFilters();
|
|
});
|
|
|
|
// Bulk action buttons
|
|
elements.bulkOtaBtn.addEventListener('click', () => {
|
|
showBulkOTAModal();
|
|
});
|
|
|
|
elements.bulkRoleBtn.addEventListener('click', () => {
|
|
showRoleModal();
|
|
});
|
|
|
|
elements.bulkRemoveBtn.addEventListener('click', () => {
|
|
showRemoveModal();
|
|
});
|
|
|
|
elements.bulkClearBtn.addEventListener('click', () => {
|
|
clearSelection();
|
|
});
|
|
|
|
// Table sort handlers
|
|
document.querySelectorAll('th.sortable').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
const column = th.dataset.sort;
|
|
handleSort(column);
|
|
});
|
|
});
|
|
|
|
// Modal close buttons
|
|
document.querySelectorAll('.modal-close').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const modalId = btn.dataset.modal;
|
|
closeModal(modalId);
|
|
});
|
|
});
|
|
|
|
// Modal backdrop click
|
|
document.querySelectorAll('.modal').forEach(modal => {
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
closeModal(modal.id);
|
|
}
|
|
});
|
|
});
|
|
|
|
// OTA confirm
|
|
elements.otaConfirmBtn.addEventListener('click', () => {
|
|
confirmOTA();
|
|
});
|
|
|
|
// Role confirm
|
|
elements.roleConfirmBtn.addEventListener('click', () => {
|
|
confirmRoleAssignment();
|
|
});
|
|
|
|
// Remove confirm
|
|
elements.removeConfirmBtn.addEventListener('click', () => {
|
|
confirmRemove();
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Data Fetching
|
|
// ============================================
|
|
async function fetchFleetData() {
|
|
try {
|
|
const response = await fetch('/api/fleet');
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const nodes = await response.json();
|
|
state.nodes = nodes || [];
|
|
|
|
// Get latest firmware version
|
|
await fetchLatestFirmware();
|
|
|
|
applyFilters();
|
|
updateSummary();
|
|
updateBulkActions();
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Failed to fetch fleet data:', error);
|
|
showToast('Failed to load fleet data', 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchLatestFirmware() {
|
|
try {
|
|
const response = await fetch('/api/firmware');
|
|
if (!response.ok) {
|
|
return; // Firmware endpoint might not be implemented yet
|
|
}
|
|
|
|
const firmware = await response.json();
|
|
if (firmware && firmware.length > 0) {
|
|
const latest = firmware.find(f => f.is_latest);
|
|
if (latest) {
|
|
state.latestFirmware = latest.version;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.debug('[FleetPage] Firmware endpoint not available:', error);
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
setInterval(() => {
|
|
fetchFleetData();
|
|
}, CONFIG.pollIntervalMs);
|
|
}
|
|
|
|
// ============================================
|
|
// Filtering and Sorting
|
|
// ============================================
|
|
function applyFilters() {
|
|
let filtered = state.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;
|
|
});
|
|
}
|
|
|
|
state.filteredNodes = filtered;
|
|
renderTable();
|
|
renderActiveFilters();
|
|
}
|
|
|
|
function getSortValue(node, column) {
|
|
switch (column) {
|
|
case 'label':
|
|
return node.name || node.label || '';
|
|
case 'mac':
|
|
return node.mac;
|
|
case 'status':
|
|
const status = getNodeStatus(node);
|
|
// Priority: online > updating > offline
|
|
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 'packetRate':
|
|
return node.packet_rate || 0;
|
|
case 'temperature':
|
|
return node.temperature || 0;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function handleSort(column) {
|
|
if (state.sortColumn === column) {
|
|
state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
state.sortColumn = column;
|
|
state.sortDirection = 'asc';
|
|
}
|
|
|
|
// Update sort indicators
|
|
document.querySelectorAll('th.sortable').forEach(th => {
|
|
th.classList.remove('sort-asc', 'sort-desc');
|
|
if (th.dataset.sort === column) {
|
|
th.classList.add(state.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
});
|
|
|
|
applyFilters();
|
|
}
|
|
|
|
function renderActiveFilters() {
|
|
let filters = [];
|
|
|
|
if (state.filters.search) {
|
|
filters.push({
|
|
type: 'search',
|
|
label: `Search: "${state.filters.search}"`,
|
|
value: 'search'
|
|
});
|
|
}
|
|
|
|
if (state.filters.status) {
|
|
const label = elements.filterStatus.options[elements.filterStatus.selectedIndex].text;
|
|
filters.push({
|
|
type: 'status',
|
|
label: `Status: ${label}`,
|
|
value: 'status'
|
|
});
|
|
}
|
|
|
|
if (state.filters.firmware) {
|
|
const label = elements.filterFirmware.options[elements.filterFirmware.selectedIndex].text;
|
|
filters.push({
|
|
type: 'firmware',
|
|
label: `Firmware: ${label}`,
|
|
value: 'firmware'
|
|
});
|
|
}
|
|
|
|
if (state.filters.roles.length > 0) {
|
|
filters.push({
|
|
type: 'roles',
|
|
label: `Roles: ${state.filters.roles.join(', ')}`,
|
|
value: 'roles'
|
|
});
|
|
}
|
|
|
|
if (filters.length === 0) {
|
|
elements.activeFilters.innerHTML = '';
|
|
elements.toolbar.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
elements.toolbar.style.display = 'block';
|
|
elements.activeFilters.innerHTML = filters.map(f => `
|
|
<span class="filter-chip" data-filter-type="${f.type}">
|
|
${f.label}
|
|
<span class="filter-dismiss" data-filter="${f.value}">×</span>
|
|
</span>
|
|
`).join('');
|
|
|
|
// Add dismiss handlers
|
|
elements.activeFilters.querySelectorAll('.filter-dismiss').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
clearFilter(btn.dataset.filter);
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearFilter(filterType) {
|
|
switch (filterType) {
|
|
case 'search':
|
|
elements.searchInput.value = '';
|
|
state.filters.search = '';
|
|
break;
|
|
case 'status':
|
|
elements.filterStatus.value = '';
|
|
state.filters.status = '';
|
|
break;
|
|
case 'firmware':
|
|
elements.filterFirmware.value = '';
|
|
state.filters.firmware = '';
|
|
break;
|
|
case 'roles':
|
|
Array.from(elements.filterRole.options).forEach(opt => opt.selected = false);
|
|
state.filters.roles = [];
|
|
break;
|
|
}
|
|
applyFilters();
|
|
}
|
|
|
|
// ============================================
|
|
// Table Rendering
|
|
// ============================================
|
|
function renderTable() {
|
|
const { filteredNodes, selectedNodes } = state;
|
|
const { tableBody, emptyState } = elements;
|
|
|
|
// Show empty state if no nodes
|
|
if (filteredNodes.length === 0) {
|
|
tableBody.innerHTML = '';
|
|
emptyState.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
|
|
// Render rows
|
|
tableBody.innerHTML = filteredNodes.map(node => {
|
|
const isSelected = selectedNodes.has(node.mac);
|
|
const status = getNodeStatus(node);
|
|
const isOutdated = isFirmwareOutdated(node);
|
|
const healthClass = getHealthClass(node.health_score);
|
|
const healthPercent = Math.round((node.health_score || 0) * 100);
|
|
const packetRateClass = getPacketRateClass(node);
|
|
const tempClass = getTemperatureClass(node.temperature);
|
|
|
|
return `
|
|
<tr class="fleet-row${isSelected ? ' selected' : ''}" data-mac="${node.mac}">
|
|
<td class="col-checkbox">
|
|
<input type="checkbox" class="checkbox node-checkbox"
|
|
data-mac="${node.mac}"${isSelected ? ' checked' : ''}>
|
|
</td>
|
|
<td class="col-label">
|
|
<div class="node-label" data-mac="${node.mac}" title="Double-click to edit">
|
|
${escapeHtml(node.name || node.label || 'Unnamed')}
|
|
</div>
|
|
</td>
|
|
<td class="col-mac">
|
|
<span class="mac-address mac-tooltip" title="${node.mac}">
|
|
${truncateMAC(node.mac)}
|
|
</span>
|
|
</td>
|
|
<td class="col-status">
|
|
<span class="status-badge ${status}">
|
|
<span class="status-dot"></span>
|
|
${capitalize(status)}
|
|
</span>
|
|
</td>
|
|
<td class="col-firmware">
|
|
<div class="firmware-version">
|
|
<span class="${isOutdated ? 'firmware-outdated' : 'firmware-current'}">
|
|
${escapeHtml(node.firmware_version || '--')}
|
|
</span>
|
|
${isOutdated ? `
|
|
<span class="firmware-indicator">
|
|
<span class="firmware-arrow">→</span>
|
|
${escapeHtml(state.latestFirmware || '?')}
|
|
</span>
|
|
` : ''}
|
|
</div>
|
|
</td>
|
|
<td class="col-uptime">
|
|
${formatUptime(node.uptime_seconds)}
|
|
</td>
|
|
<td class="col-role">
|
|
<span class="role-badge ${node.role}">${formatRole(node.role)}</span>
|
|
</td>
|
|
<td class="col-health">
|
|
<div class="health-bar-container">
|
|
<div class="health-bar">
|
|
<div class="health-bar-fill ${healthClass}" style="width: ${healthPercent}%"></div>
|
|
</div>
|
|
<span class="health-value">${healthPercent}%</span>
|
|
</div>
|
|
</td>
|
|
<td class="col-packet-rate">
|
|
<span class="packet-rate ${packetRateClass}">
|
|
${formatPacketRate(node)}
|
|
</span>
|
|
</td>
|
|
<td class="col-temperature">
|
|
<span class="temperature ${tempClass}">
|
|
${formatTemperature(node.temperature)}
|
|
</span>
|
|
</td>
|
|
<td class="col-actions">
|
|
<div class="action-buttons">
|
|
<button class="action-btn btn-locate" data-mac="${node.mac}"
|
|
title="Locate (flash LED)" ${status !== 'online' ? 'disabled' : ''}>
|
|
⚡
|
|
</button>
|
|
<button class="action-btn btn-ota" data-mac="${node.mac}"
|
|
title="Update Firmware" ${!isOutdated ? 'disabled' : ''}>
|
|
↑
|
|
</button>
|
|
<button class="action-btn btn-flyto" data-mac="${node.mac}"
|
|
title="Fly to Node">
|
|
⛶
|
|
</button>
|
|
<button class="action-btn btn-more" data-mac="${node.mac}"
|
|
title="More Actions">
|
|
…
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
// Bind row events
|
|
bindRowEvents();
|
|
}
|
|
|
|
function bindRowEvents() {
|
|
// Checkbox handlers
|
|
document.querySelectorAll('.node-checkbox').forEach(checkbox => {
|
|
checkbox.addEventListener('change', (e) => {
|
|
const mac = e.target.dataset.mac;
|
|
if (e.target.checked) {
|
|
state.selectedNodes.add(mac);
|
|
} else {
|
|
state.selectedNodes.delete(mac);
|
|
}
|
|
updateBulkActions();
|
|
renderTable(); // Re-render to update selected state
|
|
});
|
|
});
|
|
|
|
// Row click for fly-to
|
|
document.querySelectorAll('.fleet-row').forEach(row => {
|
|
row.addEventListener('click', (e) => {
|
|
// Don't trigger if clicking on checkbox, actions, or label
|
|
if (e.target.closest('.checkbox') ||
|
|
e.target.closest('.action-buttons') ||
|
|
e.target.closest('.node-label')) {
|
|
return;
|
|
}
|
|
const mac = row.dataset.mac;
|
|
flyToNode(mac);
|
|
});
|
|
});
|
|
|
|
// Label double-click for inline edit
|
|
document.querySelectorAll('.node-label').forEach(labelEl => {
|
|
labelEl.addEventListener('dblclick', (e) => {
|
|
e.stopPropagation();
|
|
const mac = labelEl.dataset.mac;
|
|
startLabelEdit(labelEl, mac);
|
|
});
|
|
});
|
|
|
|
// Action buttons
|
|
document.querySelectorAll('.btn-locate').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const mac = btn.dataset.mac;
|
|
identifyNode(mac);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-ota').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const mac = btn.dataset.mac;
|
|
showOTAModal(mac);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-flyto').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const mac = btn.dataset.mac;
|
|
flyToNode(mac);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-more').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const mac = btn.dataset.mac;
|
|
showMoreActions(btn, mac);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Inline Label Edit
|
|
// ============================================
|
|
function startLabelEdit(element, mac) {
|
|
const currentValue = element.textContent;
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
const existingLabel = node.name || node.label || '';
|
|
|
|
element.contentEditable = true;
|
|
element.classList.add('editing');
|
|
element.textContent = existingLabel;
|
|
|
|
// Focus and select all text
|
|
element.focus();
|
|
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
const handleBlur = async () => {
|
|
element.removeEventListener('blur', handleBlur);
|
|
element.removeEventListener('keydown', handleKeydown);
|
|
|
|
const newValue = element.textContent.trim();
|
|
element.contentEditable = false;
|
|
element.classList.remove('editing');
|
|
|
|
// Validate
|
|
if (newValue.length > 32) {
|
|
showToast('Label must be 32 characters or less', 'warning');
|
|
element.textContent = currentValue;
|
|
return;
|
|
}
|
|
|
|
if (newValue === currentValue) {
|
|
// No change
|
|
element.textContent = currentValue;
|
|
return;
|
|
}
|
|
|
|
// Save to server
|
|
try {
|
|
const response = await fetch(`/api/nodes/${mac}/label`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ label: newValue })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
// Update local state
|
|
const nodeIndex = state.nodes.findIndex(n => n.mac === mac);
|
|
if (nodeIndex !== -1) {
|
|
state.nodes[nodeIndex].name = newValue;
|
|
state.nodes[nodeIndex].label = newValue;
|
|
}
|
|
|
|
showToast('Label updated', 'success');
|
|
applyFilters();
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Failed to update label:', error);
|
|
showToast('Failed to update label', 'error');
|
|
element.textContent = currentValue;
|
|
}
|
|
};
|
|
|
|
const handleKeydown = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
element.blur();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
element.textContent = currentValue;
|
|
element.blur();
|
|
}
|
|
};
|
|
|
|
element.addEventListener('blur', handleBlur);
|
|
element.addEventListener('keydown', handleKeydown);
|
|
}
|
|
|
|
// ============================================
|
|
// Bulk Actions
|
|
// ============================================
|
|
function toggleSelectAll(checked) {
|
|
state.filteredNodes.forEach(node => {
|
|
if (checked) {
|
|
state.selectedNodes.add(node.mac);
|
|
} else {
|
|
state.selectedNodes.delete(node.mac);
|
|
}
|
|
});
|
|
updateBulkActions();
|
|
renderTable();
|
|
}
|
|
|
|
function clearSelection() {
|
|
state.selectedNodes.clear();
|
|
updateBulkActions();
|
|
renderTable();
|
|
}
|
|
|
|
function updateBulkActions() {
|
|
const count = state.selectedNodes.size;
|
|
elements.bulkSelectedCount.textContent = count;
|
|
elements.selectAllCheckbox.checked = count > 0 && count === state.filteredNodes.length;
|
|
elements.bulkActionsBar.style.display = count > 0 ? 'block' : 'none';
|
|
|
|
// Update button states
|
|
const hasOutdated = Array.from(state.selectedNodes).some(mac => {
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
return node && isFirmwareOutdated(node);
|
|
});
|
|
elements.bulkOtaBtn.disabled = !hasOutdated;
|
|
}
|
|
|
|
// ============================================
|
|
// Node Actions
|
|
// ============================================
|
|
async function identifyNode(mac) {
|
|
try {
|
|
showToast('Sending identify command...', 'info');
|
|
|
|
const response = await fetch(`/api/nodes/${mac}/identify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ duration_ms: 5000 })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
showToast(`Identifying ${formatMAC(mac)}`, 'success');
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Identify failed:', error);
|
|
showToast('Failed to send identify command', 'error');
|
|
}
|
|
}
|
|
|
|
function flyToNode(mac) {
|
|
// Store target MAC in localStorage for expert mode
|
|
localStorage.setItem('fleetFlyToMAC', mac);
|
|
|
|
// Redirect to expert mode
|
|
window.location.href = '/?highlight=' + mac;
|
|
}
|
|
|
|
function showOTAModal(mac) {
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
if (!node) return;
|
|
|
|
elements.otaNodeLabel.textContent = node.name || node.label || formatMAC(mac);
|
|
elements.otaCurrentVersion.textContent = node.firmware_version || 'Unknown';
|
|
elements.otaLatestVersion.textContent = state.latestFirmware || 'Unknown';
|
|
elements.otaInfoCurrent.textContent = node.firmware_version || 'Unknown';
|
|
elements.otaInfoLatest.textContent = state.latestFirmware || 'Unknown';
|
|
elements.otaInfoStatus.textContent = getNodeStatus(node);
|
|
elements.otaInfoStatus.className = 'info-value ' + getNodeStatus(node);
|
|
|
|
elements.otaModal.dataset.targetMAC = mac;
|
|
openModal('ota-modal');
|
|
}
|
|
|
|
function showBulkOTAModal() {
|
|
const macs = Array.from(state.selectedNodes);
|
|
const outdated = macs.filter(mac => {
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
return node && isFirmwareOutdated(node);
|
|
});
|
|
|
|
if (outdated.length === 0) {
|
|
showToast('No selected nodes have outdated firmware', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Show confirmation
|
|
if (outdated.length === 1) {
|
|
showOTAModal(outdated[0]);
|
|
} else {
|
|
// For multiple nodes, confirm without showing modal
|
|
if (confirm(`Update ${outdated.length} nodes to firmware ${state.latestFirmware || 'latest'}?`)) {
|
|
updateMultipleNodes(outdated);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function confirmOTA() {
|
|
const mac = elements.otaModal.dataset.targetMAC;
|
|
if (!mac) return;
|
|
|
|
closeModal('ota-modal');
|
|
|
|
try {
|
|
showToast('Starting firmware update...', 'info');
|
|
|
|
const response = await fetch(`/api/nodes/${mac}/ota`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
showToast(`Firmware update started for ${formatMAC(mac)}`, 'success');
|
|
fetchFleetData(); // Refresh to show updating status
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] OTA failed:', error);
|
|
showToast('Failed to start firmware update', 'error');
|
|
}
|
|
}
|
|
|
|
async function updateMultipleNodes(macs) {
|
|
try {
|
|
showToast(`Starting updates for ${macs.length} nodes...`, 'info');
|
|
|
|
// Update nodes in sequence with 30s stagger
|
|
for (let i = 0; i < macs.length; i++) {
|
|
setTimeout(async () => {
|
|
try {
|
|
await fetch(`/api/nodes/${macs[i]}/ota`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (i === macs.length - 1) {
|
|
showToast(`Firmware updates started for ${macs.length} nodes`, 'success');
|
|
fetchFleetData();
|
|
}
|
|
} catch (error) {
|
|
console.error(`[FleetPage] OTA failed for ${macs[i]}:`, error);
|
|
}
|
|
}, i * 30000); // 30 second stagger
|
|
}
|
|
|
|
showToast(`Firmware updates queued for ${macs.length} nodes`, 'info');
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Bulk OTA failed:', error);
|
|
showToast('Failed to start firmware updates', 'error');
|
|
}
|
|
}
|
|
|
|
async function updateAllFirmware() {
|
|
if (!confirm('Update all nodes to the latest firmware?\n\nThis will update nodes in sequence with a 30-second gap between each node.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/nodes/update-all', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
showToast(`Firmware updates started for ${data.count || 0} nodes`, 'success');
|
|
fetchFleetData();
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Update all failed:', error);
|
|
showToast('Failed to start firmware updates', 'error');
|
|
}
|
|
}
|
|
|
|
function showRoleModal() {
|
|
const count = state.selectedNodes.size;
|
|
elements.roleNodeCount.textContent = count;
|
|
openModal('role-modal');
|
|
}
|
|
|
|
async function confirmRoleAssignment() {
|
|
const selected = document.querySelector('input[name="role-assignment"]:checked');
|
|
if (!selected) {
|
|
showToast('Please select a role', 'warning');
|
|
return;
|
|
}
|
|
|
|
const newRole = selected.value;
|
|
closeModal('role-modal');
|
|
|
|
try {
|
|
const macs = Array.from(state.selectedNodes);
|
|
let successCount = 0;
|
|
|
|
for (const mac of macs) {
|
|
try {
|
|
const response = await fetch(`/api/nodes/${mac}/role`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role: newRole })
|
|
});
|
|
|
|
if (response.ok) {
|
|
successCount++;
|
|
|
|
// Update local state
|
|
const nodeIndex = state.nodes.findIndex(n => n.mac === mac);
|
|
if (nodeIndex !== -1) {
|
|
state.nodes[nodeIndex].role = newRole;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`[FleetPage] Failed to update role for ${mac}:`, error);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
showToast(`Role updated for ${successCount} nodes`, 'success');
|
|
fetchFleetData();
|
|
}
|
|
|
|
if (successCount < macs.length) {
|
|
showToast(`Failed to update ${macs.length - successCount} nodes`, 'warning');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Role assignment failed:', error);
|
|
showToast('Failed to assign roles', 'error');
|
|
}
|
|
}
|
|
|
|
function showRemoveModal() {
|
|
const macs = Array.from(state.selectedNodes);
|
|
elements.removeNodeCount.textContent = macs.length;
|
|
|
|
// List nodes to be removed
|
|
const listHTML = '<ul>' + macs.map(mac => {
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
const label = node ? (node.name || node.label || formatMAC(mac)) : formatMAC(mac);
|
|
return `<li>${escapeHtml(label)} (${escapeHtml(mac)})</li>`;
|
|
}).join('') + '</ul>';
|
|
|
|
elements.removeNodeList.innerHTML = listHTML;
|
|
openModal('remove-modal');
|
|
}
|
|
|
|
async function confirmRemove() {
|
|
closeModal('remove-modal');
|
|
|
|
if (!confirm('This action cannot be undone. Remove selected nodes from the fleet?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const macs = Array.from(state.selectedNodes);
|
|
let successCount = 0;
|
|
|
|
for (const mac of macs) {
|
|
try {
|
|
const response = await fetch(`/api/nodes/${mac}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
successCount++;
|
|
state.selectedNodes.delete(mac);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[FleetPage] Failed to remove ${mac}:`, error);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
showToast(`Removed ${successCount} nodes from fleet`, 'success');
|
|
fetchFleetData();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('[FleetPage] Remove failed:', error);
|
|
showToast('Failed to remove nodes', 'error');
|
|
}
|
|
}
|
|
|
|
function showMoreActions(button, mac) {
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
if (!node) return;
|
|
|
|
// Remove any existing dropdowns
|
|
closeAllDropdowns();
|
|
|
|
// Create dropdown menu
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'dropdown-menu visible';
|
|
dropdown.id = `dropdown-${mac}`;
|
|
|
|
const label = node.name || node.label || formatMAC(mac);
|
|
dropdown.innerHTML = `
|
|
<button class="dropdown-item" data-action="role" data-mac="${mac}">
|
|
<span class="dropdown-icon">⚙</span> Re-assign Role
|
|
</button>
|
|
<button class="dropdown-item" data-action="health-history" data-mac="${mac}">
|
|
<span class="dropdown-icon">⚓</span> View Health History
|
|
</button>
|
|
<button class="dropdown-item" data-action="event-history" data-mac="${mac}">
|
|
<span class="dropdown-icon">📅</span> View Event History
|
|
</button>
|
|
<button class="dropdown-item dropdown-divider"></button>
|
|
<button class="dropdown-item dropdown-danger" data-action="remove" data-mac="${mac}">
|
|
<span class="dropdown-icon">🗑</span> Remove from Fleet
|
|
</button>
|
|
`;
|
|
|
|
// Position the dropdown
|
|
const buttonRect = button.getBoundingClientRect();
|
|
dropdown.style.position = 'fixed';
|
|
dropdown.style.top = (buttonRect.bottom + 4) + 'px';
|
|
dropdown.style.right = (window.innerWidth - buttonRect.right) + 'px';
|
|
|
|
document.body.appendChild(dropdown);
|
|
|
|
// Bind click handlers
|
|
dropdown.querySelectorAll('.dropdown-item').forEach(item => {
|
|
item.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const action = item.dataset.action;
|
|
const targetMac = item.dataset.mac;
|
|
closeAllDropdowns();
|
|
handleMoreAction(action, targetMac);
|
|
});
|
|
});
|
|
|
|
// Close dropdown when clicking outside
|
|
setTimeout(() => {
|
|
document.addEventListener('click', closeDropdownOnClickOutside);
|
|
}, 10);
|
|
}
|
|
|
|
function closeDropdownOnClickOutside(e) {
|
|
if (!e.target.closest('.dropdown-menu') && !e.target.closest('.btn-more')) {
|
|
closeAllDropdowns();
|
|
document.removeEventListener('click', closeDropdownOnClickOutside);
|
|
}
|
|
}
|
|
|
|
function closeAllDropdowns() {
|
|
document.querySelectorAll('.dropdown-menu').forEach(menu => {
|
|
menu.remove();
|
|
});
|
|
}
|
|
|
|
async function handleMoreAction(action, mac) {
|
|
const node = state.nodes.find(n => n.mac === mac);
|
|
if (!node) return;
|
|
|
|
switch (action) {
|
|
case 'role':
|
|
// Clear existing selection and select just this node
|
|
state.selectedNodes.clear();
|
|
state.selectedNodes.add(mac);
|
|
updateBulkActions();
|
|
showRoleModal();
|
|
break;
|
|
|
|
case 'health-history':
|
|
showToast(`Health history for ${node.name || node.label || formatMAC(mac)} coming soon`, 'info');
|
|
// TODO: Implement health history view
|
|
break;
|
|
|
|
case 'event-history':
|
|
showToast(`Event history for ${node.name || node.label || formatMAC(mac)} coming soon`, 'info');
|
|
// TODO: Implement event history view
|
|
break;
|
|
|
|
case 'remove':
|
|
// Clear existing selection and select just this node
|
|
state.selectedNodes.clear();
|
|
state.selectedNodes.add(mac);
|
|
updateBulkActions();
|
|
showRemoveModal();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// CSV Export
|
|
// ============================================
|
|
function downloadCSV() {
|
|
const headers = [
|
|
'MAC',
|
|
'Label',
|
|
'Status',
|
|
'Firmware Version',
|
|
'Uptime (s)',
|
|
'Role',
|
|
'Health Score',
|
|
'Packet Rate (Hz)',
|
|
'Temperature (C)',
|
|
'Last Seen'
|
|
];
|
|
|
|
const rows = state.filteredNodes.map(node => [
|
|
node.mac,
|
|
node.name || node.label || '',
|
|
getNodeStatus(node),
|
|
node.firmware_version || '',
|
|
node.uptime_seconds || 0,
|
|
node.role,
|
|
(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);
|
|
|
|
showToast(`Downloaded ${state.filteredNodes.length} nodes to CSV`, 'success');
|
|
}
|
|
|
|
// ============================================
|
|
// Summary Update
|
|
// ============================================
|
|
function updateSummary() {
|
|
const total = state.nodes.length;
|
|
const online = state.nodes.filter(n => getNodeStatus(n) === 'online').length;
|
|
|
|
elements.totalSummary.textContent = total;
|
|
elements.onlineSummary.textContent = online;
|
|
}
|
|
|
|
// ============================================
|
|
// Helper Functions
|
|
// ============================================
|
|
function getNodeStatus(node) {
|
|
// Check if node is currently updating
|
|
if (node.ota_in_progress) {
|
|
return 'updating';
|
|
}
|
|
|
|
// Check if online (last seen within threshold)
|
|
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 'online';
|
|
}
|
|
}
|
|
|
|
return 'offline';
|
|
}
|
|
|
|
function isFirmwareOutdated(node) {
|
|
if (!state.latestFirmware || !node.firmware_version) {
|
|
return false;
|
|
}
|
|
return node.firmware_version !== state.latestFirmware;
|
|
}
|
|
|
|
function getHealthClass(score) {
|
|
if (score >= 0.7) return 'good';
|
|
if (score >= 0.4) return 'fair';
|
|
return 'poor';
|
|
}
|
|
|
|
function getPacketRateClass(node) {
|
|
const rate = node.packet_rate || 0;
|
|
const configured = node.configured_rate || 20;
|
|
const ratio = rate / configured;
|
|
|
|
if (ratio > 0.9) return 'good';
|
|
if (ratio > 0.7) return 'fair';
|
|
return 'poor';
|
|
}
|
|
|
|
function getTemperatureClass(temp) {
|
|
if (!temp) return '';
|
|
if (temp > 75) return 'alert';
|
|
return '';
|
|
}
|
|
|
|
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 formatPacketRate(node) {
|
|
const actual = node.packet_rate || 0;
|
|
const configured = node.configured_rate || 20;
|
|
return `${actual} / ${configured} Hz`;
|
|
}
|
|
|
|
function formatTemperature(temp) {
|
|
if (!temp && temp !== 0) return '--';
|
|
return `${Math.round(temp)}°C`;
|
|
}
|
|
|
|
function capitalize(str) {
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ============================================
|
|
// Modal Functions
|
|
// ============================================
|
|
function openModal(modalId) {
|
|
const modal = document.getElementById(modalId);
|
|
if (modal) {
|
|
modal.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
function closeModal(modalId) {
|
|
const modal = document.getElementById(modalId);
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Toast Notifications
|
|
// ============================================
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
|
|
const icons = {
|
|
success: '✔',
|
|
error: '✖',
|
|
warning: '⚠',
|
|
info: 'ℹ'
|
|
};
|
|
|
|
toast.innerHTML = `
|
|
<span class="toast-icon">${icons[type] || icons.info}</span>
|
|
<span class="toast-message">${escapeHtml(message)}</span>
|
|
<button class="toast-dismiss">×</button>
|
|
`;
|
|
|
|
elements.toastContainer.appendChild(toast);
|
|
|
|
// Auto-dismiss after 5 seconds
|
|
setTimeout(() => {
|
|
toast.style.animation = 'slideIn 0.3s ease reverse';
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 300);
|
|
}, 5000);
|
|
|
|
// Dismiss on click
|
|
toast.querySelector('.toast-dismiss').addEventListener('click', () => {
|
|
toast.remove();
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Utility Functions
|
|
// ============================================
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// Public API
|
|
// ============================================
|
|
window.FleetPage = {
|
|
init,
|
|
refresh: fetchFleetData,
|
|
getState: () => state
|
|
};
|
|
|
|
// ============================================
|
|
// Auto-init
|
|
// ============================================
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|