From 7338381c98f9536563785c077d7cd16648d200b3 Mon Sep 17 00:00:00 2001 From: PaulVua Date: Tue, 12 May 2026 17:53:44 +0200 Subject: [PATCH] v1.8.0: Refonte UX update firmware (progress bar live + streaming logs) L'ancien flow etait un AJAX bloquant qui attendait ~90s sans aucun retour visuel autre qu'un spinner. Nouveau flow: - Backend: launcher.php lance update_firmware.sh en background (route update_firmware_start) et expose une route de polling incremental (update_firmware_progress) avec offset. - Frontend: progress bar Bootstrap animee + label de l'etape en cours + timer mm:ss / estimation, plus streaming des logs toutes les 700ms. - Sous-etape Step 3c (la plus longue): interpolation fine de la progression en comptant les 'Started X' (services demarres). - Logs techniques masques par defaut dans
, ouverts automatiquement en cas d'echec pour faciliter le debug. Co-Authored-By: Claude Opus 4.7 (1M context) --- VERSION | 2 +- changelog.json | 22 ++++ html/admin.html | 282 ++++++++++++++++++++++++++++++++++++++-------- html/launcher.php | 67 ++++++++++- 4 files changed, 323 insertions(+), 50 deletions(-) diff --git a/VERSION b/VERSION index 91c74a5..27f9cd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.7 +1.8.0 diff --git a/changelog.json b/changelog.json index 73a50f0..a4ddf87 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,27 @@ { "versions": [ + { + "version": "1.8.0", + "date": "2026-05-12", + "changes": { + "features": [ + "Update firmware: nouvelle UX avec progress bar dynamique, label de l'étape en cours, et timer mm:ss / estimation", + "Update firmware: streaming live des logs (polling 700ms) — plus de fenêtre 'qui bloque' pendant 90s", + "Update firmware: bloc de statut final (succès vert / échec rouge) avec message explicite", + "Update firmware: logs techniques masqués par défaut dans une section repliable, ouverts automatiquement en cas d'échec" + ], + "improvements": [ + "Backend: 2 nouvelles routes launcher.php — update_firmware_start (lance en background, retour immédiat) et update_firmware_progress (polling incrémental avec offset)", + "Sous-étape Step 3c (setup_services.sh, le plus long): interpolation de la progression via comptage des 'Started X' (npm, envea, sara, etc.)", + "Sous-étape Step 4 (restart services): interpolation via comptage des 'Restarting enabled service:'" + ], + "fixes": [], + "compatibility": [ + "L'ancienne route update_firmware (synchronous) est conservée pour rétrocompatibilité" + ] + }, + "notes": "Refonte UX du process de mise à jour: l'utilisateur voit maintenant en temps réel où on en est (étape, %, temps écoulé) au lieu de fixer un spinner pendant 1-2 minutes. Les logs bruts restent accessibles pour debug pro via une section repliable." + }, { "version": "1.7.7", "date": "2026-05-12", diff --git a/html/admin.html b/html/admin.html index 5e775e8..825db14 100755 --- a/html/admin.html +++ b/html/admin.html @@ -287,7 +287,7 @@ @@ -824,6 +852,21 @@ function update_config(param, value){ }); } +// Mapping of step markers detected in the log -> progress %, step label, and weight +// for sub-step interpolation. The script normally takes ~80-100s end-to-end. +const UPDATE_STEPS = [ + { marker: 'Step 1:', percent: 5, label: 'Téléchargement du firmware' }, + { marker: 'Step 2:', percent: 12, label: 'Mise à jour de la configuration BDD' }, + { marker: 'Step 3:', percent: 18, label: 'Vérification des permissions' }, + { marker: 'Step 3c:', percent: 25, label: 'Reconfiguration des services systemd' }, + { marker: 'Step 4:', percent: 70, label: 'Redémarrage des services' }, + { marker: 'Step 5:', percent: 94, label: 'Vérification système' }, + { marker: 'Step 6:', percent: 98, label: 'Nettoyage des logs' }, + { marker: 'Update completed successfully!', percent: 100, label: '✅ Terminé !' } +]; + +let updatePollState = null; + function updateFirmware() { // Check if connected to internet (not in hotspot mode) if (window._adminConfig && window._adminConfig.WIFI_status === 'hotspot') { @@ -831,71 +874,205 @@ function updateFirmware() { return; } - console.log("Starting comprehensive firmware update..."); + console.log("Starting comprehensive firmware update (background mode)..."); - // Show loading state + // UI elements const updateBtn = document.getElementById('updateBtn'); const updateBtnText = document.getElementById('updateBtnText'); const updateSpinner = document.getElementById('updateSpinner'); const updateOutput = document.getElementById('updateOutput'); const updateOutputContent = document.getElementById('updateOutputContent'); - - // Disable button and show spinner + const finalStatus = document.getElementById('updateFinalStatus'); + const reloadBtn = document.getElementById('reloadBtn'); + + // Reset UI updateBtn.disabled = true; updateBtnText.textContent = 'Updating...'; updateSpinner.style.display = 'inline-block'; - - // Show output console updateOutput.style.display = 'block'; - updateOutputContent.textContent = 'Starting update process...\n'; - + updateOutputContent.textContent = ''; + finalStatus.style.display = 'none'; + finalStatus.className = 'alert mb-3'; + reloadBtn.style.display = 'none'; + setProgress(0, 'Démarrage...'); + + // Initial timer + const startTime = Date.now(); + updateTimer(startTime); + const timerInterval = setInterval(() => updateTimer(startTime), 1000); + + // Start the background update $.ajax({ - url: 'launcher.php?type=update_firmware', + url: 'launcher.php?type=update_firmware_start', method: 'GET', dataType: 'json', - timeout: 120000, // 2 minutes timeout - + timeout: 10000, success: function(response) { - console.log('Update completed:', response); - - // Display formatted output - if (response.success && response.output) { - // Format the output for better readability - const formattedOutput = response.output - .replace(/\[\d{2}:\d{2}:\d{2}\]/g, function(match) { - return `${match}`; - }) - .replace(/✓/g, '') - .replace(/✗/g, '') - .replace(/⚠/g, '') - .replace(/ℹ/g, ''); - - updateOutputContent.innerHTML = formattedOutput; - - // Show success toast and reload button - showToast('Update completed successfully!', 'success'); - document.getElementById('reloadBtn').style.display = 'inline-block'; - } else { - updateOutputContent.textContent = 'Update completed but no output received.'; - showToast('Update may have completed with issues', 'warning'); + if (!response.success) { + clearInterval(timerInterval); + showUpdateError('Impossible de lancer la mise à jour: ' + (response.error || 'erreur inconnue')); + resetUpdateButton(); + return; } + // Begin polling progress + updatePollState = { offset: 0, allContent: '', startTime, timerInterval }; + pollUpdateProgress(); }, - error: function(xhr, status, error) { - console.error('Update failed:', status, error); - updateOutputContent.textContent = `Update failed: ${error}\n\nStatus: ${status}\nResponse: ${xhr.responseText || 'No response'}`; - showToast('Update failed! Check the output for details.', 'error'); - }, - - complete: function() { - // Reset button state - updateBtn.disabled = false; - updateBtnText.textContent = 'Update firmware'; - updateSpinner.style.display = 'none'; + clearInterval(timerInterval); + showUpdateError('Erreur de communication: ' + error); + resetUpdateButton(); } }); } +function pollUpdateProgress() { + if (!updatePollState) return; + + $.ajax({ + url: 'launcher.php?type=update_firmware_progress&offset=' + updatePollState.offset, + method: 'GET', + dataType: 'json', + timeout: 5000, + success: function(response) { + if (!response.success) { + finishUpdate(false, 'Réponse serveur invalide'); + return; + } + + // Append new content + if (response.content) { + updatePollState.allContent += response.content; + updatePollState.offset = response.offset; + appendUpdateLog(response.content); + refreshProgressFromContent(updatePollState.allContent); + } + + // Check if finished + if (response.done) { + const success = updatePollState.allContent.includes('Update completed successfully!') + && !updatePollState.allContent.includes('EXIT_CODE=1'); + finishUpdate(success); + return; + } + + // Continue polling + setTimeout(pollUpdateProgress, 700); + }, + error: function(xhr, status, error) { + // Network blip: retry once after a short delay + setTimeout(pollUpdateProgress, 1500); + } + }); +} + +function appendUpdateLog(newContent) { + const pre = document.getElementById('updateOutputContent'); + const formatted = newContent + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\[\d{2}:\d{2}:\d{2}\]/g, '$&') + .replace(/✓/g, '') + .replace(/✗/g, '') + .replace(/⚠/g, '') + .replace(/ℹ/g, ''); + pre.innerHTML += formatted; + pre.scrollTop = pre.scrollHeight; +} + +function refreshProgressFromContent(allContent) { + // Find the last (most advanced) step marker present in the log + let bestStep = null; + for (const step of UPDATE_STEPS) { + if (allContent.includes(step.marker)) bestStep = step; + } + if (!bestStep) return; + + let percent = bestStep.percent; + let label = bestStep.label; + + // Sub-step interpolation: within Step 3c (25 -> 70) and Step 4 (70 -> 94) + // we count finer-grained markers to make progress feel smoother on slow steps. + if (bestStep.marker === 'Step 3c:') { + const startedCount = (allContent.match(/Started [\w-]+(?: timer| service)/g) || []).length; + // Up to ~11 services are started by setup_services.sh -> step 3c spans 25-65 + percent = Math.min(65, 25 + startedCount * 4); + } else if (bestStep.marker === 'Step 4:') { + const restartedCount = (allContent.match(/Restarting enabled service:/g) || []).length; + // ~7 services restarted in step 4 -> spans 70-92 + percent = Math.min(92, 70 + restartedCount * 3); + } + + setProgress(percent, label); +} + +function setProgress(percent, label) { + const bar = document.getElementById('updateProgressBar'); + bar.style.width = percent + '%'; + bar.textContent = percent + '%'; + if (label) document.getElementById('updateStepLabel').textContent = label; +} + +function updateTimer(startTime) { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + const mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); + const ss = String(elapsed % 60).padStart(2, '0'); + document.getElementById('updateTimerElapsed').textContent = `${mm}:${ss}`; +} + +function finishUpdate(success, errorMsg) { + if (updatePollState && updatePollState.timerInterval) { + clearInterval(updatePollState.timerInterval); + } + + const finalStatus = document.getElementById('updateFinalStatus'); + const bar = document.getElementById('updateProgressBar'); + const reloadBtn = document.getElementById('reloadBtn'); + const techDetails = document.getElementById('updateTechLogDetails'); + + if (success) { + setProgress(100, '✅ Terminé !'); + bar.classList.remove('progress-bar-animated', 'bg-primary'); + bar.classList.add('bg-success'); + finalStatus.className = 'alert alert-success mb-3'; + finalStatus.innerHTML = 'Mise à jour terminée avec succès. Vous pouvez recharger la page pour voir la nouvelle version.'; + finalStatus.style.display = 'block'; + reloadBtn.style.display = 'inline-block'; + showToast('Update completed successfully!', 'success'); + } else { + bar.classList.remove('progress-bar-animated', 'bg-primary'); + bar.classList.add('bg-danger'); + finalStatus.className = 'alert alert-danger mb-3'; + finalStatus.innerHTML = 'Échec de la mise à jour. ' + + (errorMsg ? errorMsg + ' ' : '') + + 'Consultez les logs techniques ci-dessous pour plus de détails.'; + finalStatus.style.display = 'block'; + // Auto-open the technical logs on failure so the user sees what went wrong + if (techDetails) techDetails.open = true; + showToast('Update failed - check technical logs', 'error'); + } + + resetUpdateButton(); + updatePollState = null; +} + +function showUpdateError(message) { + const finalStatus = document.getElementById('updateFinalStatus'); + finalStatus.className = 'alert alert-danger mb-3'; + finalStatus.textContent = message; + finalStatus.style.display = 'block'; +} + +function resetUpdateButton() { + const updateBtn = document.getElementById('updateBtn'); + const updateBtnText = document.getElementById('updateBtnText'); + const updateSpinner = document.getElementById('updateSpinner'); + updateBtn.disabled = false; + updateBtnText.textContent = 'Update firmware'; + updateSpinner.style.display = 'none'; +} + function uploadFirmware() { const fileInput = document.getElementById('firmwareFileInput'); const file = fileInput.files[0]; @@ -1010,10 +1187,19 @@ function clearUpdateOutput() { const updateOutput = document.getElementById('updateOutput'); const updateOutputContent = document.getElementById('updateOutputContent'); const reloadBtn = document.getElementById('reloadBtn'); - - updateOutputContent.textContent = ''; + const finalStatus = document.getElementById('updateFinalStatus'); + const bar = document.getElementById('updateProgressBar'); + + updateOutputContent.innerHTML = ''; updateOutput.style.display = 'none'; reloadBtn.style.display = 'none'; + if (finalStatus) finalStatus.style.display = 'none'; + if (bar) { + bar.style.width = '0%'; + bar.textContent = '0%'; + bar.classList.remove('bg-success', 'bg-danger'); + bar.classList.add('progress-bar-animated', 'bg-primary'); + } } function showToast(message, type) { diff --git a/html/launcher.php b/html/launcher.php index 141abe3..641421d 100755 --- a/html/launcher.php +++ b/html/launcher.php @@ -418,7 +418,7 @@ if ($type == "update_firmware") { // Execute the comprehensive update script $command = 'sudo /var/www/nebuleair_pro_4g/update_firmware.sh 2>&1'; $output = shell_exec($command); - + // Return the output as JSON for better web display header('Content-Type: application/json'); echo json_encode([ @@ -428,6 +428,71 @@ if ($type == "update_firmware") { ]); } +// Start firmware update in background, returns immediately so the UI can poll progress. +// Output is written to a temp log file. A 'done' marker file is created when finished. +if ($type == "update_firmware_start") { + $logFile = '/tmp/nebuleair_firmware_update.log'; + $doneFile = '/tmp/nebuleair_firmware_update.done'; + + // Reset previous run markers + @file_put_contents($logFile, ''); + @unlink($doneFile); + + // Launch in background: + // - run the update script, capture stdout/stderr into log + // - append "EXIT_CODE=N" so the UI can detect success/failure + // - touch the done file so the UI knows the run finished + // - detach all stdio from PHP so this call returns immediately + $cmd = '(sudo /var/www/nebuleair_pro_4g/update_firmware.sh > ' + . escapeshellarg($logFile) . ' 2>&1; ' + . 'echo "EXIT_CODE=$?" >> ' . escapeshellarg($logFile) . '; ' + . 'touch ' . escapeshellarg($doneFile) . ') > /dev/null 2>&1 &'; + shell_exec($cmd); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'started_at' => time(), + 'log_file' => $logFile + ]); + exit; +} + +// Poll firmware update progress. The UI sends the byte offset it has already read, +// we return any new content since that offset and whether the run has finished. +if ($type == "update_firmware_progress") { + $logFile = '/tmp/nebuleair_firmware_update.log'; + $doneFile = '/tmp/nebuleair_firmware_update.done'; + + $offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0; + + $content = ''; + $newOffset = $offset; + if (file_exists($logFile)) { + $size = filesize($logFile); + if ($size > $offset) { + $fp = fopen($logFile, 'r'); + if ($fp) { + fseek($fp, $offset); + $content = fread($fp, $size - $offset); + fclose($fp); + } + $newOffset = $size; + } + } + + $done = file_exists($doneFile); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'content' => $content, + 'offset' => $newOffset, + 'done' => $done + ]); + exit; +} + if ($type == "upload_firmware") { // Firmware update via ZIP file upload (offline mode) if ($_SERVER['REQUEST_METHOD'] !== 'POST') {