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:
@@ -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",
|
||||
|
||||
262
html/admin.html
262
html/admin.html
@@ -287,7 +287,7 @@
|
||||
<div id="updateOutput" class="mt-3" style="display: none;">
|
||||
<div class="card">
|
||||
<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>
|
||||
<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">
|
||||
@@ -302,7 +302,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -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,69 +874,203 @@ 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');
|
||||
const finalStatus = document.getElementById('updateFinalStatus');
|
||||
const reloadBtn = document.getElementById('reloadBtn');
|
||||
|
||||
// Disable button and show spinner
|
||||
// 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);
|
||||
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) {
|
||||
clearInterval(timerInterval);
|
||||
showUpdateError('Erreur de communication: ' + error);
|
||||
resetUpdateButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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 `<span style="color: #007bff; font-weight: bold;">${match}</span>`;
|
||||
})
|
||||
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(/>/g, '>')
|
||||
.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;
|
||||
}
|
||||
|
||||
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');
|
||||
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;
|
||||
|
||||
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');
|
||||
},
|
||||
let percent = bestStep.percent;
|
||||
let label = bestStep.label;
|
||||
|
||||
complete: function() {
|
||||
// Reset button state
|
||||
// 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() {
|
||||
@@ -1010,10 +1187,19 @@ function clearUpdateOutput() {
|
||||
const updateOutput = document.getElementById('updateOutput');
|
||||
const updateOutputContent = document.getElementById('updateOutputContent');
|
||||
const reloadBtn = document.getElementById('reloadBtn');
|
||||
const finalStatus = document.getElementById('updateFinalStatus');
|
||||
const bar = document.getElementById('updateProgressBar');
|
||||
|
||||
updateOutputContent.textContent = '';
|
||||
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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user