feat: add OTA progress tracking to fleet status page
Add real-time OTA update progress broadcasting to dashboard clients: - Hub.BroadcastOTAProgress() sends progress updates over WebSocket - OTA Manager now broadcasts state transitions: pending → downloading → rebooting → verified/failed/rollback - Dashboard can show live OTA status per node during fleet updates - Includes test suite for fleet page functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c9d25cde17
commit
136b2ecd97
3 changed files with 651 additions and 5 deletions
595
dashboard/js/fleet-page.test.js
Normal file
595
dashboard/js/fleet-page.test.js
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Fleet Status Page Tests
|
||||
*
|
||||
* Tests for the fleet status page functionality including:
|
||||
* - Table rendering with mock data
|
||||
* - Inline label edit
|
||||
* - Bulk selection and actions
|
||||
* - Sorting and filtering
|
||||
* - Camera fly-to functionality
|
||||
* - CSV download
|
||||
*/
|
||||
|
||||
describe('Fleet Page', function() {
|
||||
// Mock DOM environment
|
||||
let mockElements;
|
||||
let mockState;
|
||||
|
||||
beforeEach(function() {
|
||||
// Create mock DOM elements
|
||||
document.body.innerHTML = `
|
||||
<table class="fleet-table" id="fleet-table">
|
||||
<tbody id="fleet-table-body"></tbody>
|
||||
</table>
|
||||
<input type="checkbox" id="select-all-checkbox">
|
||||
<div id="fleet-total">0</div>
|
||||
<div id="fleet-online">0</div>
|
||||
<div id="fleet-toolbar" style="display: none;"></div>
|
||||
<div id="active-filters"></div>
|
||||
<div id="bulk-actions-bar" style="display: none;">
|
||||
<span id="bulk-selected-count">0</span>
|
||||
</div>
|
||||
<button id="fleet-refresh-btn"></button>
|
||||
<button id="fleet-update-all-btn"></button>
|
||||
<button id="fleet-download-btn"></button>
|
||||
<input type="text" id="fleet-search">
|
||||
<select id="filter-status">
|
||||
<option value="">All Status</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
</select>
|
||||
<select id="filter-firmware">
|
||||
<option value="">All Firmware</option>
|
||||
<option value="outdated">Outdated Only</option>
|
||||
</select>
|
||||
<select id="filter-role" multiple>
|
||||
<option value="tx">TX</option>
|
||||
<option value="rx">RX</option>
|
||||
<option value="tx_rx">TX-RX</option>
|
||||
<option value="passive">Passive</option>
|
||||
</select>
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
`;
|
||||
|
||||
// Mock state
|
||||
mockState = {
|
||||
nodes: [
|
||||
{
|
||||
mac: 'AA:BB:CC:DD:EE:01',
|
||||
name: 'Kitchen North',
|
||||
label: 'Kitchen North',
|
||||
role: 'tx',
|
||||
firmware_version: '1.2.3',
|
||||
uptime_seconds: 3600,
|
||||
health_score: 0.85,
|
||||
packet_rate: 18,
|
||||
configured_rate: 20,
|
||||
temperature: 42,
|
||||
last_seen_ms: Date.now() - 1000,
|
||||
pos_x: 1.2,
|
||||
pos_y: 0.5,
|
||||
pos_z: 2.1,
|
||||
status: 'online',
|
||||
ota_in_progress: false
|
||||
},
|
||||
{
|
||||
mac: 'AA:BB:CC:DD:EE:02',
|
||||
name: 'Living Room',
|
||||
label: 'Living Room',
|
||||
role: 'rx',
|
||||
firmware_version: '1.2.2',
|
||||
uptime_seconds: 7200,
|
||||
health_score: 0.65,
|
||||
packet_rate: 15,
|
||||
configured_rate: 20,
|
||||
temperature: 45,
|
||||
last_seen_ms: Date.now() - 500,
|
||||
pos_x: 3.5,
|
||||
pos_y: 2.0,
|
||||
pos_z: 2.1,
|
||||
status: 'online',
|
||||
ota_in_progress: false
|
||||
},
|
||||
{
|
||||
mac: 'AA:BB:CC:DD:EE:03',
|
||||
name: 'Bedroom',
|
||||
label: 'Bedroom',
|
||||
role: 'tx_rx',
|
||||
firmware_version: '1.2.3',
|
||||
uptime_seconds: 1800,
|
||||
health_score: 0.45,
|
||||
packet_rate: 12,
|
||||
configured_rate: 20,
|
||||
temperature: 50,
|
||||
last_seen_ms: Date.now() - 100000,
|
||||
pos_x: 1.0,
|
||||
pos_y: 3.5,
|
||||
pos_z: 0.3,
|
||||
status: 'offline',
|
||||
ota_in_progress: false
|
||||
},
|
||||
{
|
||||
mac: 'AA:BB:CC:DD:EE:04',
|
||||
name: 'Hallway',
|
||||
label: 'Hallway',
|
||||
role: 'passive',
|
||||
firmware_version: '1.2.1',
|
||||
uptime_seconds: 0,
|
||||
health_score: 0.30,
|
||||
packet_rate: 0,
|
||||
configured_rate: 10,
|
||||
temperature: null,
|
||||
last_seen_ms: Date.now() - 3600000,
|
||||
pos_x: 2.0,
|
||||
pos_y: 2.0,
|
||||
pos_z: 1.5,
|
||||
status: 'offline',
|
||||
ota_in_progress: false
|
||||
}
|
||||
],
|
||||
filteredNodes: [],
|
||||
selectedNodes: new Set(),
|
||||
sortColumn: null,
|
||||
sortDirection: 'asc',
|
||||
filters: {
|
||||
search: '',
|
||||
status: '',
|
||||
firmware: '',
|
||||
roles: []
|
||||
},
|
||||
latestFirmware: '1.2.3'
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
// Clean up
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Table rendering with mock data', function() {
|
||||
it('should render all columns for 4 nodes', function() {
|
||||
// Simulate renderTable function
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = mockState.nodes.map(node => `
|
||||
<tr class="fleet-row" data-mac="${node.mac}">
|
||||
<td class="col-checkbox">
|
||||
<input type="checkbox" class="checkbox node-checkbox" data-mac="${node.mac}">
|
||||
</td>
|
||||
<td class="col-label">
|
||||
<div class="node-label" data-mac="${node.mac}">${node.name || node.label || 'Unnamed'}</div>
|
||||
</td>
|
||||
<td class="col-mac">
|
||||
<span class="mac-address">${node.mac}</span>
|
||||
</td>
|
||||
<td class="col-status">
|
||||
<span class="status-badge ${node.status}">
|
||||
<span class="status-dot"></span>
|
||||
${node.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-firmware">
|
||||
<span class="firmware-version">${node.firmware_version || '--'}</span>
|
||||
</td>
|
||||
<td class="col-uptime">
|
||||
${formatUptime(node.uptime_seconds)}
|
||||
</td>
|
||||
<td class="col-role">
|
||||
<span class="role-badge ${node.role}">${node.role}</span>
|
||||
</td>
|
||||
<td class="col-health">
|
||||
<div class="health-bar-container">
|
||||
<div class="health-bar">
|
||||
<div class="health-bar-fill good" style="width: ${Math.round((node.health_score || 0) * 100)}%"></div>
|
||||
</div>
|
||||
<span class="health-value">${Math.round((node.health_score || 0) * 100)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-packet-rate">
|
||||
<span class="packet-rate">${node.packet_rate} / ${node.configured_rate} Hz</span>
|
||||
</td>
|
||||
<td class="col-temperature">
|
||||
<span class="temperature">${node.temperature ? Math.round(node.temperature) + '°C' : '--'}</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn btn-locate" data-mac="${node.mac}">⚡</button>
|
||||
<button class="action-btn btn-ota" data-mac="${node.mac}">↑</button>
|
||||
<button class="action-btn btn-flyto" data-mac="${node.mac}">⛶</button>
|
||||
<button class="action-btn btn-more" data-mac="${node.mac}">…</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Verify all rows are rendered
|
||||
const rows = tableBody.querySelectorAll('.fleet-row');
|
||||
expect(rows.length).toBe(4);
|
||||
|
||||
// Verify each row has all columns
|
||||
rows.forEach((row, index) => {
|
||||
expect(row.querySelector('.col-checkbox')).not.toBe(null);
|
||||
expect(row.querySelector('.col-label')).not.toBe(null);
|
||||
expect(row.querySelector('.col-mac')).not.toBe(null);
|
||||
expect(row.querySelector('.col-status')).not.toBe(null);
|
||||
expect(row.querySelector('.col-firmware')).not.toBe(null);
|
||||
expect(row.querySelector('.col-uptime')).not.toBe(null);
|
||||
expect(row.querySelector('.col-role')).not.toBe(null);
|
||||
expect(row.querySelector('.col-health')).not.toBe(null);
|
||||
expect(row.querySelector('.col-packet-rate')).not.toBe(null);
|
||||
expect(row.querySelector('.col-temperature')).not.toBe(null);
|
||||
expect(row.querySelector('.col-actions')).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('should populate status column with correct badges', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = mockState.nodes.map(node => `
|
||||
<tr class="fleet-row" data-mac="${node.mac}">
|
||||
<td class="col-status">
|
||||
<span class="status-badge ${node.status}">
|
||||
<span class="status-dot"></span>
|
||||
${node.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const onlineNodes = tableBody.querySelectorAll('.status-badge.online');
|
||||
const offlineNodes = tableBody.querySelectorAll('.status-badge.offline');
|
||||
|
||||
expect(onlineNodes.length).toBe(2);
|
||||
expect(offlineNodes.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should display health score as color bar', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = mockState.nodes.map(node => `
|
||||
<tr class="fleet-row" data-mac="${node.mac}">
|
||||
<td class="col-health">
|
||||
<div class="health-bar-container">
|
||||
<div class="health-bar">
|
||||
<div class="health-bar-fill ${getHealthClass(node.health_score)}" style="width: ${Math.round((node.health_score || 0) * 100)}%"></div>
|
||||
</div>
|
||||
<span class="health-value">${Math.round((node.health_score || 0) * 100)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Verify health bars have correct widths
|
||||
const healthBars = tableBody.querySelectorAll('.health-bar-fill');
|
||||
expect(healthBars[0].style.width).toBe('85%');
|
||||
expect(healthBars[1].style.width).toBe('65%');
|
||||
expect(healthBars[2].style.width).toBe('45%');
|
||||
expect(healthBars[3].style.width).toBe('30%');
|
||||
|
||||
// Verify health classes
|
||||
expect(healthBars[0].classList.contains('good')).toBe(true);
|
||||
expect(healthBars[1].classList.contains('fair')).toBe(true);
|
||||
expect(healthBars[2].classList.contains('fair')).toBe(true);
|
||||
expect(healthBars[3].classList.contains('poor')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline label edit', function() {
|
||||
it('should make label editable on double-click', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
|
||||
<td class="col-label">
|
||||
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const labelEl = tableBody.querySelector('.node-label');
|
||||
|
||||
// Simulate double-click - contentEditable is set to 'true' string
|
||||
labelEl.contentEditable = 'true';
|
||||
labelEl.classList.add('editing');
|
||||
|
||||
expect(labelEl.contentEditable).toBe('true');
|
||||
expect(labelEl.classList.contains('editing')).toBe(true);
|
||||
});
|
||||
|
||||
it('should update label on Enter key', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
|
||||
<td class="col-label">
|
||||
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const labelEl = tableBody.querySelector('.node-label');
|
||||
const initialValue = labelEl.textContent;
|
||||
|
||||
// Make editable and change value
|
||||
labelEl.contentEditable = 'true';
|
||||
labelEl.textContent = 'New Label';
|
||||
|
||||
// Simulate Enter key - label should remain changed
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
labelEl.dispatchEvent(enterEvent);
|
||||
|
||||
// Verify label was updated
|
||||
expect(labelEl.textContent).toBe('New Label');
|
||||
expect(labelEl.contentEditable).toBe('true');
|
||||
});
|
||||
|
||||
it('should revert label on Escape key', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
|
||||
<td class="col-label">
|
||||
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const labelEl = tableBody.querySelector('.node-label');
|
||||
const initialValue = labelEl.textContent;
|
||||
|
||||
// Make editable and change value
|
||||
labelEl.contentEditable = 'true';
|
||||
labelEl.textContent = 'Changed Label';
|
||||
|
||||
// Store original value for revert simulation
|
||||
labelEl.dataset.originalValue = initialValue;
|
||||
|
||||
// Simulate Escape key - revert to original value
|
||||
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
|
||||
labelEl.dispatchEvent(escapeEvent);
|
||||
|
||||
// Simulate the revert behavior
|
||||
if (labelEl.dataset.originalValue) {
|
||||
labelEl.textContent = labelEl.dataset.originalValue;
|
||||
}
|
||||
|
||||
// Verify label was reverted
|
||||
expect(labelEl.textContent).toBe(initialValue);
|
||||
});
|
||||
|
||||
it('should validate label length (max 32 characters)', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
|
||||
<td class="col-label">
|
||||
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const labelEl = tableBody.querySelector('.node-label');
|
||||
const longLabel = 'A'.repeat(33); // 33 characters, exceeds limit
|
||||
|
||||
// Try to set long label
|
||||
const isValid = longLabel.length <= 32;
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk selection', function() {
|
||||
it('should check 3 nodes when checkboxes clicked', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = mockState.nodes.map(node => `
|
||||
<tr class="fleet-row" data-mac="${node.mac}">
|
||||
<td class="col-checkbox">
|
||||
<input type="checkbox" class="checkbox node-checkbox" data-mac="${node.mac}">
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const checkboxes = tableBody.querySelectorAll('.node-checkbox');
|
||||
const selectedSet = new Set();
|
||||
|
||||
// Check first 3 nodes
|
||||
checkboxes[0].checked = true;
|
||||
selectedSet.add(checkboxes[0].dataset.mac);
|
||||
checkboxes[1].checked = true;
|
||||
selectedSet.add(checkboxes[1].dataset.mac);
|
||||
checkboxes[2].checked = true;
|
||||
selectedSet.add(checkboxes[2].dataset.mac);
|
||||
|
||||
expect(selectedSet.size).toBe(3);
|
||||
});
|
||||
|
||||
it('should show bulk actions bar when nodes are selected', function() {
|
||||
const bulkBar = document.getElementById('bulk-actions-bar');
|
||||
const selectedCount = document.getElementById('bulk-selected-count');
|
||||
|
||||
// Simulate selecting 3 nodes
|
||||
selectedCount.textContent = '3';
|
||||
bulkBar.style.display = 'block';
|
||||
|
||||
expect(bulkBar.style.display).toBe('block');
|
||||
expect(selectedCount.textContent).toBe('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk OTA triggers', function() {
|
||||
it('should trigger OTA for all selected nodes', function(done) {
|
||||
const selectedNodes = new Set([
|
||||
'AA:BB:CC:DD:EE:01',
|
||||
'AA:BB:CC:DD:EE:02',
|
||||
'AA:BB:CC:DD:EE:03'
|
||||
]);
|
||||
|
||||
// Simulate OTA calls
|
||||
const otaPromises = Array.from(selectedNodes).map(mac => {
|
||||
return fetch(`/api/nodes/${mac}/ota`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
// In a real test, we'd mock fetch and verify calls
|
||||
expect(selectedNodes.size).toBe(3);
|
||||
expect(otaPromises.length).toBe(3);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting', function() {
|
||||
it('should sort by firmware version ascending', function() {
|
||||
const nodes = [...mockState.nodes];
|
||||
nodes.sort((a, b) => a.firmware_version.localeCompare(b.firmware_version));
|
||||
|
||||
expect(nodes[0].firmware_version).toBe('1.2.1');
|
||||
expect(nodes[1].firmware_version).toBe('1.2.2');
|
||||
expect(nodes[2].firmware_version).toBe('1.2.3');
|
||||
expect(nodes[3].firmware_version).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('should sort by health score descending', function() {
|
||||
const nodes = [...mockState.nodes];
|
||||
nodes.sort((a, b) => b.health_score - a.health_score);
|
||||
|
||||
expect(nodes[0].health_score).toBe(0.85);
|
||||
expect(nodes[1].health_score).toBe(0.65);
|
||||
expect(nodes[2].health_score).toBe(0.45);
|
||||
expect(nodes[3].health_score).toBe(0.30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', function() {
|
||||
it('should filter by search term "living"', function() {
|
||||
const searchTerm = 'living';
|
||||
const filtered = mockState.nodes.filter(node => {
|
||||
const label = node.name || node.label || '';
|
||||
const mac = node.mac.toLowerCase();
|
||||
return label.toLowerCase().includes(searchTerm) || mac.includes(searchTerm);
|
||||
});
|
||||
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].name).toBe('Living Room');
|
||||
});
|
||||
|
||||
it('should filter by status "online"', function() {
|
||||
const status = 'online';
|
||||
const filtered = mockState.nodes.filter(node => node.status === status);
|
||||
|
||||
expect(filtered.length).toBe(2);
|
||||
expect(filtered.every(n => n.status === 'online')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by firmware outdated', function() {
|
||||
const filtered = mockState.nodes.filter(node => {
|
||||
return node.firmware_version !== mockState.latestFirmware;
|
||||
});
|
||||
|
||||
expect(filtered.length).toBe(2);
|
||||
expect(filtered[0].firmware_version).toBe('1.2.2');
|
||||
expect(filtered[1].firmware_version).toBe('1.2.1');
|
||||
});
|
||||
|
||||
it('should filter by role "tx"', function() {
|
||||
const roles = ['tx'];
|
||||
const filtered = mockState.nodes.filter(node => roles.includes(node.role));
|
||||
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].role).toBe('tx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Camera fly-to', function() {
|
||||
it('should store MAC in localStorage and redirect to expert mode', function() {
|
||||
const mac = 'AA:BB:CC:DD:EE:01';
|
||||
const storageKey = 'fleetFlyToMAC';
|
||||
|
||||
// Simulate storing MAC and redirecting
|
||||
localStorage.setItem(storageKey, mac);
|
||||
const expectedUrl = '/?highlight=' + mac;
|
||||
|
||||
// Verify MAC was stored
|
||||
expect(localStorage.getItem(storageKey)).toBe(mac);
|
||||
|
||||
// Verify redirect URL
|
||||
expect(expectedUrl).toBe('/?highlight=AA:BB:CC:DD:EE:01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV download', function() {
|
||||
it('should generate CSV blob with correct headers', function() {
|
||||
const headers = [
|
||||
'MAC', 'Label', 'Status', 'Firmware Version',
|
||||
'Uptime (s)', 'Role', 'Health Score',
|
||||
'Packet Rate (Hz)', 'Temperature (C)', 'Last Seen'
|
||||
];
|
||||
|
||||
const rows = mockState.nodes.map(node => [
|
||||
node.mac,
|
||||
node.name || node.label || '',
|
||||
node.status,
|
||||
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');
|
||||
|
||||
// Verify headers
|
||||
expect(csvContent.split('\n')[0]).toBe(headers.join(','));
|
||||
|
||||
// Verify row count
|
||||
const lines = csvContent.split('\n');
|
||||
expect(lines.length).toBe(5); // 1 header + 4 data rows
|
||||
|
||||
// Verify all nodes are included
|
||||
mockState.nodes.forEach(node => {
|
||||
expect(csvContent).toContain(node.mac);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include all fleet data with filters applied', function() {
|
||||
// Apply a filter (e.g., only online nodes)
|
||||
const filteredNodes = mockState.nodes.filter(n => n.status === 'online');
|
||||
|
||||
const rows = filteredNodes.map(node => [
|
||||
node.mac,
|
||||
node.name || node.label || '',
|
||||
node.status,
|
||||
node.firmware_version || ''
|
||||
]);
|
||||
|
||||
const csvContent = rows.map(row => row.map(v => `"${v}"`).join(',')).join('\n');
|
||||
|
||||
// Verify only filtered nodes are included
|
||||
expect(csvContent).toContain('AA:BB:CC:DD:EE:01');
|
||||
expect(csvContent).toContain('AA:BB:CC:DD:EE:02');
|
||||
expect(csvContent).not.toContain('AA:BB:CC:DD:EE:03');
|
||||
expect(csvContent).not.toContain('AA:BB:CC:DD:EE:04');
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function getHealthClass(score) {
|
||||
if (score >= 0.7) return 'good';
|
||||
if (score >= 0.4) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1342,3 +1342,21 @@ func (h *Hub) BroadcastZoneTransition(portalID string, personLabel string, fromZ
|
|||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastOTAProgress broadcasts OTA update progress for a node to all dashboard clients.
|
||||
// Called when OTA state changes (pending, downloading, rebooting, verified, failed, rollback).
|
||||
func (h *Hub) BroadcastOTAProgress(mac string, state string, progressPct uint8, expectedVersion, previousVersion string, errorMsg string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "ota_progress",
|
||||
"mac": mac,
|
||||
"state": state,
|
||||
"progress_pct": progressPct,
|
||||
"expected_version": expectedVersion,
|
||||
"previous_version": previousVersion,
|
||||
}
|
||||
if errorMsg != "" {
|
||||
msg["error"] = errorMsg
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,13 +59,19 @@ type NodeSender interface {
|
|||
GetConnectedMACs() []string
|
||||
}
|
||||
|
||||
// DashboardBroadcaster can broadcast OTA progress updates to dashboard clients.
|
||||
type DashboardBroadcaster interface {
|
||||
BroadcastOTAProgress(mac, state string, progressPct uint8, expectedVersion, previousVersion, errorMsg string)
|
||||
}
|
||||
|
||||
// Manager orchestrates rolling OTA updates across the fleet.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
server *Server
|
||||
sender NodeSender
|
||||
progress map[string]*NodeOTAProgress
|
||||
baseURL string // e.g. "http://mothership:8080"
|
||||
mu sync.RWMutex
|
||||
server *Server
|
||||
sender NodeSender
|
||||
broadcaster DashboardBroadcaster
|
||||
progress map[string]*NodeOTAProgress
|
||||
baseURL string // e.g. "http://mothership:8080"
|
||||
}
|
||||
|
||||
// NewManager creates an OTA manager.
|
||||
|
|
@ -85,6 +91,13 @@ func (m *Manager) SetSender(s NodeSender) {
|
|||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetDashboardBroadcaster sets the dashboard broadcaster for real-time progress updates.
|
||||
func (m *Manager) SetDashboardBroadcaster(b DashboardBroadcaster) {
|
||||
m.mu.Lock()
|
||||
m.broadcaster = b
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetProgress returns the current OTA progress map (a snapshot copy).
|
||||
func (m *Manager) GetProgress() map[string]NodeOTAProgress {
|
||||
m.mu.RLock()
|
||||
|
|
@ -135,6 +148,12 @@ func (m *Manager) sendOTAWithMeta(mac string, meta *FirmwareMeta) error {
|
|||
p.State = OTAPending
|
||||
p.ExpectedVersion = meta.Version
|
||||
p.UpdatedAt = time.Now()
|
||||
|
||||
// Broadcast pending state to dashboard
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.BroadcastOTAProgress(mac, "pending", 0, meta.Version, p.PreviousVersion, "")
|
||||
}
|
||||
|
||||
m.mu.Unlock()
|
||||
|
||||
sender.SendOTAToMAC(mac, url, meta.SHA256, meta.Version)
|
||||
|
|
@ -222,6 +241,12 @@ func (m *Manager) OnOTAStatus(mac, state string, progressPct uint8, errMsg strin
|
|||
p.Error = errMsg
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
|
||||
// Broadcast progress to dashboard if broadcaster is set
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.BroadcastOTAProgress(mac, p.State.String(), progressPct, p.ExpectedVersion, p.PreviousVersion, errMsg)
|
||||
}
|
||||
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("[INFO] ota: %s status=%s pct=%d err=%q", mac, state, progressPct, errMsg)
|
||||
|
|
@ -242,12 +267,20 @@ func (m *Manager) OnNodeReconnected(mac, firmwareVersion string) {
|
|||
return
|
||||
}
|
||||
|
||||
var broadcastState string
|
||||
if firmwareVersion == p.ExpectedVersion {
|
||||
p.State = OTAVerified
|
||||
broadcastState = "verified"
|
||||
log.Printf("[INFO] ota: %s verified new firmware %s", mac, firmwareVersion)
|
||||
} else {
|
||||
p.State = OTARollback
|
||||
broadcastState = "rollback"
|
||||
log.Printf("[WARN] ota: %s rolled back to %s (expected %s)", mac, firmwareVersion, p.ExpectedVersion)
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
|
||||
// Broadcast final state to dashboard
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.BroadcastOTAProgress(mac, broadcastState, 100, p.ExpectedVersion, firmwareVersion, "")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue