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 <details>, ouverts
  automatiquement en cas d'echec pour faciliter le debug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PaulVua
2026-05-12 17:53:44 +02:00
parent 5d7aac38e1
commit 7338381c98
4 changed files with 323 additions and 50 deletions

View File

@@ -1 +1 @@
1.7.7 1.8.0

View File

@@ -1,5 +1,27 @@
{ {
"versions": [ "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", "version": "1.7.7",
"date": "2026-05-12", "date": "2026-05-12",

View File

@@ -287,7 +287,7 @@
<div id="updateOutput" class="mt-3" style="display: none;"> <div id="updateOutput" class="mt-3" style="display: none;">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold">Update Log</span> <span class="fw-bold">Mise à jour en cours</span>
<div> <div>
<button type="button" class="btn btn-sm btn-success me-2" onclick="location.reload()" id="reloadBtn" style="display: none;"> <button type="button" class="btn btn-sm btn-success me-2" onclick="location.reload()" id="reloadBtn" style="display: none;">
<svg width="14" height="14" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16"> <svg width="14" height="14" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
@@ -302,7 +302,35 @@
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<pre id="updateOutputContent" class="mb-0" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; background-color: #f8f9fa; padding: 1rem; border-radius: 0.375rem;"></pre>
<!-- Live progress UI -->
<div id="updateProgressSection">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong id="updateStepLabel" class="text-primary">Démarrage...</strong>
<span class="text-muted small">
<span id="updateTimerElapsed">00:00</span> / <span id="updateTimerEstimate" class="text-muted">~01:30</span>
</span>
</div>
<div class="progress mb-2" style="height: 22px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
id="updateProgressBar" role="progressbar"
style="width: 0%; transition: width 0.5s ease;">0%</div>
</div>
<small class="text-muted d-block mb-3">
<i class="bi bi-info-circle"></i> Ne pas fermer ni rafraîchir cette page pendant la mise à jour.
</small>
</div>
<!-- Final status banner (hidden until done) -->
<div id="updateFinalStatus" class="alert mb-3" style="display: none;"></div>
<!-- Collapsible technical log -->
<details id="updateTechLogDetails">
<summary class="text-muted small mb-2" style="cursor: pointer;">
Logs techniques (cliquer pour ouvrir)
</summary>
<pre id="updateOutputContent" class="mb-0 mt-2" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; background-color: #f8f9fa; padding: 1rem; border-radius: 0.375rem;"></pre>
</details>
</div> </div>
</div> </div>
</div> </div>
@@ -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() { function updateFirmware() {
// Check if connected to internet (not in hotspot mode) // Check if connected to internet (not in hotspot mode)
if (window._adminConfig && window._adminConfig.WIFI_status === 'hotspot') { if (window._adminConfig && window._adminConfig.WIFI_status === 'hotspot') {
@@ -831,71 +874,205 @@ function updateFirmware() {
return; 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 updateBtn = document.getElementById('updateBtn');
const updateBtnText = document.getElementById('updateBtnText'); const updateBtnText = document.getElementById('updateBtnText');
const updateSpinner = document.getElementById('updateSpinner'); const updateSpinner = document.getElementById('updateSpinner');
const updateOutput = document.getElementById('updateOutput'); const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent'); const updateOutputContent = document.getElementById('updateOutputContent');
const finalStatus = document.getElementById('updateFinalStatus');
const reloadBtn = document.getElementById('reloadBtn');
// Disable button and show spinner // Reset UI
updateBtn.disabled = true; updateBtn.disabled = true;
updateBtnText.textContent = 'Updating...'; updateBtnText.textContent = 'Updating...';
updateSpinner.style.display = 'inline-block'; updateSpinner.style.display = 'inline-block';
// Show output console
updateOutput.style.display = 'block'; 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({ $.ajax({
url: 'launcher.php?type=update_firmware', url: 'launcher.php?type=update_firmware_start',
method: 'GET', method: 'GET',
dataType: 'json', dataType: 'json',
timeout: 120000, // 2 minutes timeout timeout: 10000,
success: function(response) { success: function(response) {
console.log('Update completed:', response); if (!response.success) {
clearInterval(timerInterval);
// Display formatted output showUpdateError('Impossible de lancer la mise à jour: ' + (response.error || 'erreur inconnue'));
if (response.success && response.output) { resetUpdateButton();
// Format the output for better readability return;
const formattedOutput = response.output
.replace(/\[\d{2}:\d{2}:\d{2}\]/g, function(match) {
return `<span style="color: #007bff; font-weight: bold;">${match}</span>`;
})
.replace(/✓/g, '<span style="color: #28a745;">✓</span>')
.replace(/✗/g, '<span style="color: #dc3545;">✗</span>')
.replace(/⚠/g, '<span style="color: #ffc107;">⚠</span>')
.replace(//g, '<span style="color: #17a2b8;"></span>');
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');
} }
// Begin polling progress
updatePollState = { offset: 0, allContent: '', startTime, timerInterval };
pollUpdateProgress();
}, },
error: function(xhr, status, error) { error: function(xhr, status, error) {
console.error('Update failed:', status, error); clearInterval(timerInterval);
updateOutputContent.textContent = `Update failed: ${error}\n\nStatus: ${status}\nResponse: ${xhr.responseText || 'No response'}`; showUpdateError('Erreur de communication: ' + error);
showToast('Update failed! Check the output for details.', 'error'); resetUpdateButton();
},
complete: function() {
// Reset button state
updateBtn.disabled = false;
updateBtnText.textContent = 'Update firmware';
updateSpinner.style.display = 'none';
} }
}); });
} }
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\[\d{2}:\d{2}:\d{2}\]/g, '<span style="color: #007bff; font-weight: bold;">$&</span>')
.replace(/✓/g, '<span style="color: #28a745;">✓</span>')
.replace(/✗/g, '<span style="color: #dc3545;">✗</span>')
.replace(/⚠/g, '<span style="color: #ffc107;">⚠</span>')
.replace(//g, '<span style="color: #17a2b8;"></span>');
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 = '<strong>Mise à jour terminée avec succès.</strong> 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 = '<strong>Échec de la mise à jour.</strong> '
+ (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() { function uploadFirmware() {
const fileInput = document.getElementById('firmwareFileInput'); const fileInput = document.getElementById('firmwareFileInput');
const file = fileInput.files[0]; const file = fileInput.files[0];
@@ -1010,10 +1187,19 @@ function clearUpdateOutput() {
const updateOutput = document.getElementById('updateOutput'); const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent'); const updateOutputContent = document.getElementById('updateOutputContent');
const reloadBtn = document.getElementById('reloadBtn'); const reloadBtn = document.getElementById('reloadBtn');
const finalStatus = document.getElementById('updateFinalStatus');
const bar = document.getElementById('updateProgressBar');
updateOutputContent.textContent = ''; updateOutputContent.innerHTML = '';
updateOutput.style.display = 'none'; updateOutput.style.display = 'none';
reloadBtn.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) { function showToast(message, type) {

View File

@@ -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") { if ($type == "upload_firmware") {
// Firmware update via ZIP file upload (offline mode) // Firmware update via ZIP file upload (offline mode)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {