/** * 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

Available Firmware
Update Progress
`; 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(); } })();