/**
* Spaxel Dashboard - OTA Firmware Management
*
* Provides UI for firmware updates: list available versions,
* trigger rolling updates, display progress, and show rollback warnings.
*/
(function() {
'use strict';
// State
const state = {
firmwareList: [],
progress: {},
pollInterval: null,
otaInProgress: false
};
// ============================================
// DOM Elements
// ============================================
let panel, firmwareList, progressList, updateAllBtn, closeBtn;
// ============================================
// Initialization
// ============================================
function init() {
createPanel();
createTriggerButton();
startPolling();
}
function createPanel() {
// Create OTA panel (hidden by default)
panel = document.createElement('div');
panel.id = 'ota-panel';
panel.style.cssText = `
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 400px;
max-height: 60vh;
background: rgba(0, 0, 0, 0.9);
border-radius: 8px;
padding: 16px;
z-index: 200;
display: none;
overflow-y: auto;
`;
panel.innerHTML = `
Firmware Updates
`;
document.body.appendChild(panel);
// Get references
firmwareList = document.getElementById('ota-firmware-list');
progressList = document.getElementById('ota-progress-list');
updateAllBtn = document.getElementById('ota-update-all-btn');
closeBtn = document.getElementById('ota-close-btn');
// Event handlers
closeBtn.addEventListener('click', hide);
updateAllBtn.addEventListener('click', triggerUpdateAll);
}
function createTriggerButton() {
// Add OTA button to status bar
const statusBar = document.getElementById('status-bar');
if (!statusBar) return;
const btn = document.createElement('button');
btn.id = 'ota-btn';
btn.textContent = 'OTA';
btn.style.cssText = `
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #888;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
`;
btn.addEventListener('click', toggle);
// Insert before FPS counter
const fpsItem = statusBar.querySelector('.status-item:last-child');
if (fpsItem) {
statusBar.insertBefore(btn, fpsItem);
} else {
statusBar.appendChild(btn);
}
}
// ============================================
// Panel Visibility
// ============================================
function toggle() {
if (panel.style.display === 'none') {
show();
} else {
hide();
}
}
function show() {
panel.style.display = 'block';
fetchFirmwareList();
fetchProgress();
}
function hide() {
panel.style.display = 'none';
}
// ============================================
// API Calls
// ============================================
async function fetchFirmwareList() {
try {
const resp = await fetch('/api/firmware');
if (!resp.ok) throw new Error('Failed to fetch firmware list');
state.firmwareList = await resp.json();
renderFirmwareList();
} catch (e) {
console.error('[OTA] Failed to fetch firmware:', e);
firmwareList.innerHTML = 'Failed to load
';
}
}
async function fetchProgress() {
try {
const resp = await fetch('/api/firmware/progress');
if (!resp.ok) return;
state.progress = await resp.json();
renderProgress();
updateButtonState();
} catch (e) {
console.error('[OTA] Failed to fetch progress:', e);
}
}
async function triggerUpdateAll() {
if (state.otaInProgress) return;
updateAllBtn.disabled = true;
updateAllBtn.textContent = 'Starting...';
try {
const resp = await fetch('/api/firmware/ota-all', { method: 'POST' });
if (!resp.ok) throw new Error('Failed to start OTA');
state.otaInProgress = true;
updateAllBtn.textContent = 'Update in Progress...';
updateAllBtn.style.background = '#ffa726';
} catch (e) {
console.error('[OTA] Failed to trigger update:', e);
updateAllBtn.disabled = false;
updateAllBtn.textContent = 'Update All Nodes';
alert('Failed to start firmware update: ' + e.message);
}
}
// ============================================
// Rendering
// ============================================
function renderFirmwareList() {
if (!state.firmwareList || state.firmwareList.length === 0) {
firmwareList.innerHTML = 'No firmware available. Upload via /api/firmware/upload
';
return;
}
let html = '';
state.firmwareList.forEach(function(fw) {
const latestBadge = fw.is_latest
? 'LATEST'
: '';
const sizeKB = Math.round(fw.size_bytes / 1024);
html += `
${escapeHtml(fw.filename)}
${latestBadge}
${sizeKB} KB
SHA256: ${fw.sha256.substring(0, 12)}...
`;
});
firmwareList.innerHTML = html;
}
function renderProgress() {
const entries = Object.entries(state.progress);
if (entries.length === 0) {
progressList.innerHTML = 'No updates in progress
';
return;
}
let html = '';
entries.forEach(function([mac, p]) {
const stateInfo = getStateInfo(p.state);
const progressBar = renderProgressBar(p.progress_pct, stateInfo.color);
const rollbackBadge = p.state === 'rollback'
? 'ROLLBACK'
: '';
html += `
${mac}
${stateInfo.label}
${rollbackBadge}
${progressBar}
${p.error ? `
${escapeHtml(p.error)}
` : ''}
${p.expected_version ? `
Target: ${escapeHtml(p.expected_version)}
` : ''}
`;
});
progressList.innerHTML = html;
// Check for any active updates
const hasActive = entries.some(function([_, p]) {
return ['pending', 'downloading', 'rebooting'].includes(p.state);
});
if (!hasActive && state.otaInProgress) {
state.otaInProgress = false;
updateButtonState();
}
// Update status bar button state
updateStatusBarButton();
// Trigger node list refresh if app.js is available
if (window.SpaxelApp && typeof SpaxelApp.refreshNodeList === 'function') {
SpaxelApp.refreshNodeList();
}
}
function updateStatusBarButton() {
const btn = document.getElementById('ota-btn');
if (!btn) return;
const entries = Object.entries(state.progress);
const hasRollback = entries.some(function([_, p]) { return p.state === 'rollback'; });
const hasActive = entries.some(function([_, p]) {
return ['pending', 'downloading', 'rebooting'].includes(p.state);
});
btn.classList.remove('has-update', 'in-progress');
if (hasRollback) {
btn.classList.add('has-update');
btn.textContent = 'OTA!';
} else if (hasActive) {
btn.classList.add('in-progress');
btn.textContent = 'OTA...';
} else {
btn.textContent = 'OTA';
}
}
function renderProgressBar(pct, color) {
const pctVal = pct || 0;
return `
`;
}
function getStateInfo(s) {
switch (s) {
case 'idle': return { label: 'Idle', color: '#888' };
case 'pending': return { label: 'Pending', color: '#4fc3f7' };
case 'downloading': return { label: 'Downloading', color: '#29b6f6' };
case 'rebooting': return { label: 'Rebooting', color: '#ffa726' };
case 'verified': return { label: 'Verified', color: '#66bb6a' };
case 'failed': return { label: 'Failed', color: '#ef5350' };
case 'rollback': return { label: 'Rollback', color: '#ef5350' };
default: return { label: s || 'Unknown', color: '#888' };
}
}
function updateButtonState() {
if (state.otaInProgress) {
updateAllBtn.disabled = true;
updateAllBtn.textContent = 'Update in Progress...';
updateAllBtn.style.background = '#ffa726';
} else {
updateAllBtn.disabled = false;
updateAllBtn.textContent = 'Update All Nodes';
updateAllBtn.style.background = '#4fc3f7';
}
}
// ============================================
// Polling
// ============================================
function startPolling() {
if (state.pollInterval) return;
state.pollInterval = setInterval(function() {
if (panel.style.display !== 'none' || state.otaInProgress) {
fetchProgress();
}
}, 2000);
}
// ============================================
// Utilities
// ============================================
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&').replace(//g, '>');
}
// ============================================
// Public API
// ============================================
window.SpaxelOTA = {
init: init,
show: show,
hide: hide,
toggle: toggle,
getProgress: function() { return state.progress; },
isInProgress: function() { return state.otaInProgress; }
};
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();