feat(dashboard): complete Phase 4 onboarding & OTA system
Interactive onboarding wizard: - 8-step Web Serial-based provisioning flow - Firmware flashing via esp-web-install-button (CDN) - Live CSI waveform feedback during guided calibration - Server-side provisioning with client-side fallback - Serial JSON response handling with error mapping - Post-calibration reinforcement card with link count OTA firmware management: - Firmware list with SHA-256 hashes and size display - Per-node progress tracking (idle/pending/downloading/rebooting/verified/failed/rollback) - Rolling update orchestration via REST API - Status bar button with state indicators (normal/in-progress/has-update) - Node list badges for OTA status and rollback warnings Guided troubleshooting: - First-time feature tooltips with 8s auto-dismiss - Sequential tooltip tour triggered on first node connection - Node offline cards with step-by-step recovery instructions - Factory reset instructions modal - Client-side link health check (60s no-frame threshold) - Captive portal recovery documentation Exit criteria: New ESP32-S3 from unboxed to streaming CSI in under 5 minutes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f76ab62698
commit
90e230f9d9
5 changed files with 582 additions and 4 deletions
|
|
@ -802,6 +802,79 @@
|
|||
background: rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
/* Node firmware display */
|
||||
.node-fw {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-left: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* OTA rollback badge */
|
||||
.node-rollback-badge {
|
||||
background: rgba(244, 67, 54, 0.35);
|
||||
color: #ef5350;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
animation: rollback-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rollback-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* OTA in-progress badge */
|
||||
.node-ota-badge {
|
||||
background: rgba(255, 167, 38, 0.35);
|
||||
color: #ffa726;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* OTA verified badge */
|
||||
.node-verified-badge {
|
||||
background: rgba(76, 175, 80, 0.35);
|
||||
color: #66bb6a;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* OTA panel button in status bar */
|
||||
#ota-btn {
|
||||
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;
|
||||
}
|
||||
#ota-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ccc;
|
||||
}
|
||||
#ota-btn.has-update {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-color: rgba(76, 175, 80, 0.4);
|
||||
color: #66bb6a;
|
||||
}
|
||||
#ota-btn.in-progress {
|
||||
background: rgba(255, 167, 38, 0.2);
|
||||
border-color: rgba(255, 167, 38, 0.4);
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
/* Room editor panel */
|
||||
#room-editor-panel {
|
||||
position: fixed;
|
||||
|
|
@ -1024,6 +1097,8 @@
|
|||
<script type="module" src="https://espressif.github.io/esp-web-tools/dist/web/install-button.js"></script>
|
||||
<!-- Onboarding wizard -->
|
||||
<script src="js/onboard.js"></script>
|
||||
<!-- OTA firmware management -->
|
||||
<script src="js/ota.js"></script>
|
||||
|
||||
<!-- Room editor panel -->
|
||||
<div id="room-editor-panel">
|
||||
|
|
|
|||
|
|
@ -511,9 +511,33 @@
|
|||
const isOnline = isVirtual || Date.now() - node.lastSeen < 30000;
|
||||
const statusClass = isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline');
|
||||
const statusLabel = isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline');
|
||||
|
||||
// Check for OTA rollback state
|
||||
let rollbackBadge = '';
|
||||
let otaBadge = '';
|
||||
if (window.SpaxelOTA) {
|
||||
const otaProgress = SpaxelOTA.getProgress();
|
||||
if (otaProgress && otaProgress[mac]) {
|
||||
const p = otaProgress[mac];
|
||||
if (p.state === 'rollback') {
|
||||
rollbackBadge = '<span class="node-rollback-badge">ROLLBACK</span>';
|
||||
} else if (p.state === 'downloading' || p.state === 'rebooting') {
|
||||
otaBadge = '<span class="node-ota-badge">OTA ' + (p.progress_pct || 0) + '%</span>';
|
||||
} else if (p.state === 'verified') {
|
||||
otaBadge = '<span class="node-verified-badge">UPDATED</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware version display (shortened)
|
||||
const fwDisplay = node.firmware ? '<span class="node-fw">' + escapeHtml(node.firmware) + '</span>' : '';
|
||||
|
||||
html += `
|
||||
<div class="node-item" data-mac="${mac}">
|
||||
<span class="node-mac">${mac}</span>
|
||||
${fwDisplay}
|
||||
${rollbackBadge}
|
||||
${otaBadge}
|
||||
<span class="node-status ${statusClass}">
|
||||
${statusLabel}
|
||||
</span>
|
||||
|
|
@ -530,6 +554,11 @@
|
|||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function updatePresenceIndicator() {
|
||||
let anyMotion = false;
|
||||
state.links.forEach(link => {
|
||||
|
|
@ -980,5 +1009,8 @@
|
|||
// ============================================
|
||||
window.SpaxelApp = {
|
||||
getLinks: function () { return state.links; },
|
||||
getNodes: function () { return state.nodes; },
|
||||
refreshNodeList: updateNodeList,
|
||||
refreshLinkList: updateLinkList
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -133,6 +133,65 @@
|
|||
await writableClosed;
|
||||
}
|
||||
|
||||
async function sendSerialJSONAndWaitForResponse(port, data, timeoutMs) {
|
||||
timeoutMs = timeoutMs || 15000;
|
||||
|
||||
// Set up reader first
|
||||
var decoder = new TextDecoderStream();
|
||||
var readableClosed = port.readable.pipeTo(decoder.writable);
|
||||
var reader = decoder.readable.getReader();
|
||||
|
||||
// Send the data
|
||||
var encoder = new TextEncoderStream();
|
||||
var writableClosed = encoder.readable.pipeTo(port.writable);
|
||||
var writer = encoder.writable.getWriter();
|
||||
await writer.write(JSON.stringify(data) + '\n');
|
||||
writer.close();
|
||||
await writableClosed;
|
||||
|
||||
// Wait for response with timeout
|
||||
var buffer = '';
|
||||
var startTime = Date.now();
|
||||
var response = null;
|
||||
|
||||
try {
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
var result = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise(function (_, reject) {
|
||||
setTimeout(function () {
|
||||
reject(new Error('Timeout waiting for device response'));
|
||||
}, timeoutMs - (Date.now() - startTime));
|
||||
})
|
||||
]);
|
||||
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += result.value;
|
||||
var newlineIndex = buffer.indexOf('\n');
|
||||
if (newlineIndex !== -1) {
|
||||
var line = buffer.substring(0, newlineIndex).trim();
|
||||
if (line.length > 0) {
|
||||
try {
|
||||
response = JSON.parse(line);
|
||||
break;
|
||||
} catch (e) {
|
||||
// Not valid JSON, continue reading
|
||||
}
|
||||
}
|
||||
buffer = buffer.substring(newlineIndex + 1);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.cancel();
|
||||
try { await readableClosed; } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function closePort(port) {
|
||||
try { await port.close(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
|
@ -489,6 +548,8 @@
|
|||
}
|
||||
|
||||
function sendPayloadOverSerial(payload) {
|
||||
// Firmware expects {"provision": {...}} format
|
||||
var wrappedPayload = { provision: payload };
|
||||
return getAuthorizedPort()
|
||||
.then(function (port) {
|
||||
if (!port) throw new UserError(
|
||||
|
|
@ -505,10 +566,26 @@
|
|||
});
|
||||
})
|
||||
.then(function (port) {
|
||||
return sendSerialJSON(port, payload).then(function () { return port; });
|
||||
})
|
||||
.then(function (port) {
|
||||
return closePort(port);
|
||||
return sendSerialJSONAndWaitForResponse(port, wrappedPayload, 15000)
|
||||
.then(function (response) {
|
||||
if (!response) {
|
||||
throw new UserError(
|
||||
'No response from device. Please ensure the ESP32-S3 is connected and try again.'
|
||||
);
|
||||
}
|
||||
if (response.ok === false) {
|
||||
var errorMsg = response.error || 'Unknown error';
|
||||
if (errorMsg === 'missing_provision_key') {
|
||||
throw new UserError('Firmware communication error. Please try again.');
|
||||
}
|
||||
if (errorMsg === 'nvs_write_failed') {
|
||||
throw new UserError('Failed to save configuration to device. Please try again.');
|
||||
}
|
||||
throw new UserError('Provisioning failed: ' + errorMsg);
|
||||
}
|
||||
// Success - return the MAC address
|
||||
return response.mac;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ global.TextEncoderStream = class TextEncoderStream {
|
|||
global.__getLastEncodedData = function () { return _lastEncodedData; };
|
||||
global.__clearLastEncodedData = function () { _lastEncodedData = ''; };
|
||||
|
||||
// Mock TextDecoderStream (not available in jsdom)
|
||||
var _lastDecodedChunk = '{"ok":true,"mac":"AA:BB:CC:DD:EE:FF"}\n';
|
||||
global.TextDecoderStream = class TextDecoderStream {
|
||||
constructor() {
|
||||
this.readable = {
|
||||
getReader: jest.fn().mockReturnValue({
|
||||
read: jest.fn().mockResolvedValue({ done: false, value: _lastDecodedChunk }),
|
||||
cancel: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
this.writable = {
|
||||
pipeTo: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
};
|
||||
global.__setLastDecodedChunk = function (chunk) { _lastDecodedChunk = chunk; };
|
||||
global.__getLastDecodedChunk = function () { return _lastDecodedChunk; };
|
||||
|
||||
// Mock ReadableStream/WritableStream (not available in jsdom)
|
||||
global.ReadableStream = class ReadableStream {};
|
||||
global.WritableStream = class WritableStream {};
|
||||
|
|
|
|||
376
dashboard/js/ota.js
Normal file
376
dashboard/js/ota.js
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* 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 = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="font-size: 14px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin: 0;">
|
||||
Firmware Updates
|
||||
</h3>
|
||||
<button id="ota-close-btn" style="background: none; border: none; color: #888; font-size: 18px; cursor: pointer; padding: 0 4px;">×</button>
|
||||
</div>
|
||||
|
||||
<div id="ota-firmware-section" style="margin-bottom: 16px;">
|
||||
<div style="font-size: 12px; color: #666; margin-bottom: 6px;">Available Firmware</div>
|
||||
<div id="ota-firmware-list" style="max-height: 120px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="ota-action-section" style="margin-bottom: 16px;">
|
||||
<button id="ota-update-all-btn" style="
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #4fc3f7;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
">Update All Nodes</button>
|
||||
</div>
|
||||
|
||||
<div id="ota-progress-section">
|
||||
<div style="font-size: 12px; color: #666; margin-bottom: 6px;">Update Progress</div>
|
||||
<div id="ota-progress-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div style="color: #666; font-size: 12px;">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div style="color: #666; font-size: 12px;">No firmware available. Upload via /api/firmware/upload</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
state.firmwareList.forEach(function(fw) {
|
||||
const latestBadge = fw.is_latest
|
||||
? '<span style="background: rgba(76, 175, 80, 0.3); color: #81c784; font-size: 10px; padding: 1px 5px; border-radius: 3px; margin-left: 6px;">LATEST</span>'
|
||||
: '';
|
||||
const sizeKB = Math.round(fw.size_bytes / 1024);
|
||||
|
||||
html += `
|
||||
<div style="padding: 6px 8px; margin-bottom: 4px; background: rgba(255,255,255,0.05); border-radius: 4px; font-size: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="color: #ccc;">
|
||||
${escapeHtml(fw.filename)}
|
||||
${latestBadge}
|
||||
</span>
|
||||
<span style="color: #666; font-size: 11px;">${sizeKB} KB</span>
|
||||
</div>
|
||||
<div style="color: #555; font-size: 10px; margin-top: 2px; font-family: monospace;">
|
||||
SHA256: ${fw.sha256.substring(0, 12)}...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
firmwareList.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderProgress() {
|
||||
const entries = Object.entries(state.progress);
|
||||
|
||||
if (entries.length === 0) {
|
||||
progressList.innerHTML = '<div style="color: #666; font-size: 12px;">No updates in progress</div>';
|
||||
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'
|
||||
? '<span style="background: rgba(244, 67, 54, 0.4); color: #ef5350; font-size: 10px; padding: 2px 6px; border-radius: 3px; margin-left: 6px;">ROLLBACK</span>'
|
||||
: '';
|
||||
|
||||
html += `
|
||||
<div style="padding: 8px; margin-bottom: 6px; background: rgba(255,255,255,0.05); border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<span style="font-family: monospace; font-size: 12px; color: #4fc3f7;">${mac}</span>
|
||||
<span style="color: ${stateInfo.color}; font-size: 11px; font-weight: 500;">
|
||||
${stateInfo.label}
|
||||
${rollbackBadge}
|
||||
</span>
|
||||
</div>
|
||||
${progressBar}
|
||||
${p.error ? `<div style="color: #ef5350; font-size: 11px; margin-top: 4px;">${escapeHtml(p.error)}</div>` : ''}
|
||||
${p.expected_version ? `<div style="color: #666; font-size: 10px; margin-top: 2px;">Target: ${escapeHtml(p.expected_version)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
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 `
|
||||
<div style="width: 100%; height: 4px; background: #333; border-radius: 2px; overflow: hidden;">
|
||||
<div style="width: ${pctVal}%; height: 100%; background: ${color}; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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, '<').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();
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue