fix: version 3.0.1 -- Actualizador espera a backend y frontend antes de recargar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:59:48 -05:00
parent e65111a7f4
commit 959c365d5c
20 changed files with 262 additions and 86 deletions
@@ -1,58 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { RefreshCw, Download } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { getUpdateStatus, applyUpdate } from '../../services/updater.js';
import { useAsync } from '../../hooks/useAsync.js';
import { getUpdateStatus, applyUpdate, getApplyProgress, getFrontendVersion } from '../../services/updater.js';
import { activateUpdateAndReload } from '../../services/serviceWorker.js';
import { useModalFocus } from '../../hooks/useModalFocus.js';
import { Button } from '../../components/Button.jsx';
import { Badge } from '../../components/Badge.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
const CRITICALITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' };
const POLL_INTERVAL_MS = 5000;
const POLL_MAX_ATTEMPTS = 60; // ~5 minutos tope
const POLL_TIMEOUT_MS = 10 * 60 * 1000;
const PHASE_KEYS = {
backup: 'updater_phase_backup', source: 'updater_phase_source', backend: 'updater_phase_backend',
frontend: 'updater_phase_frontend', publish: 'updater_phase_publish', restart: 'updater_phase_restart',
complete: 'updater_phase_verify',
};
export default function UpdaterPanel() {
const { t, openDialog, pushToast } = useUi();
const { data: status, loading, error, reload, setData } = useAsync(getUpdateStatus, []);
const runAction = useAction();
const { t, openDialog } = useUi();
const { data: status, loading, error, reload } = useAsync(getUpdateStatus, []);
const [applying, setApplying] = useState(false);
const [progressPct, setProgressPct] = useState(0);
const [phase, setPhase] = useState('backup');
const [applyError, setApplyError] = useState(null);
const mountedRef = useRef(true);
const pollingRef = useRef(false);
const modalRef = useModalFocus(applying);
// Setup explicito (no solo el cleanup): con React.StrictMode en dev el efecto se dispara
// mount->cleanup->mount de una, y si el setup no reactiva el flag queda en false para siempre
// aunque el componente SI este montado -- pollUntilUpToDate nunca correria.
// aunque el componente SI este montado -- el sondeo nunca correria.
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
// El servidor se reinicia a mitad de la actualizacion (backend y frontend), asi que durante
// ese rato las llamadas van a fallar por un momento -- se ignoran y se sigue insistiendo. El
// porcentaje es una estimacion sobre el tope de espera (no hay progreso real del script), pero
// alcanza para que la persona sepa que sigue avanzando y no quede mirando una pantalla muerta.
// Al confirmar que ya esta al dia, se recarga la pestaña sola para traer el build nuevo.
async function pollUntilUpToDate() {
for (let i = 0; i < POLL_MAX_ATTEMPTS && mountedRef.current; i++) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
if (!mountedRef.current) return;
setProgressPct(Math.min(95, Math.round(((i + 1) / POLL_MAX_ATTEMPTS) * 100)));
try {
const result = await getUpdateStatus();
// El estado del script confirma el final; ambas versiones comprueban lo que realmente se sirve.
async function pollUntilReady(targetVersion, startedAt) {
if (pollingRef.current) return;
pollingRef.current = true;
setApplying(true);
setApplyError(null);
try {
while (mountedRef.current && Date.now() - startedAt < POLL_TIMEOUT_MS) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
if (!mountedRef.current) return;
setData(result);
if (result.upToDate) {
setProgressPct(100);
await new Promise((resolve) => setTimeout(resolve, 600));
window.location.reload();
return;
setProgressPct(Math.min(95, Math.round(((Date.now() - startedAt) / POLL_TIMEOUT_MS) * 100)));
try {
const progress = await getApplyProgress();
if (progress.targetVersion !== targetVersion) continue;
setPhase(progress.phase);
if (progress.state === 'failed') {
setApplyError(t('updater_apply_failed'));
return;
}
if (progress.state === 'done' && progress.runningVersion === targetVersion) {
const frontendVersion = await getFrontendVersion();
if (frontendVersion !== targetVersion) continue;
setProgressPct(100);
await activateUpdateAndReload();
return;
}
} catch {
// Se toleran cortes del backend/frontend mientras reinician los servicios.
}
} catch {
// backend reiniciandose -- se ignora y se reintenta en el proximo ciclo
}
if (mountedRef.current) setApplyError(t('updater_apply_timeout'));
} finally {
pollingRef.current = false;
}
if (mountedRef.current) {
setApplying(false);
pushToast({ tone: 'warning', message: t('updater_apply_timeout') });
}
useEffect(() => {
const pending = sessionStorage.getItem('plc_update_pending');
if (!pending) return;
try {
const { targetVersion, startedAt } = JSON.parse(pending);
if (targetVersion && Number.isFinite(startedAt)) pollUntilReady(targetVersion, startedAt);
} catch { sessionStorage.removeItem('plc_update_pending'); }
// Solo al montar: recupera la espera si la pestaña fue recargada.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function startApply() {
setApplying(true);
setProgressPct(0);
setPhase('backup');
setApplyError(null);
try {
const result = await applyUpdate();
const pending = { targetVersion: result.toVersion, startedAt: Date.now() };
sessionStorage.setItem('plc_update_pending', JSON.stringify(pending));
pollUntilReady(pending.targetVersion, pending.startedAt);
} catch (err) {
setApplyError(err.message);
}
}
@@ -65,12 +107,7 @@ export default function UpdaterPanel() {
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('updater_apply_confirm_ok'), variant: 'danger',
onClick: () => runAction(async () => {
setProgressPct(0);
setApplying(true);
await applyUpdate();
pushToast({ tone: 'success', message: t('updater_apply_started') });
}).then(pollUntilUpToDate).catch(() => setApplying(false)),
onClick: () => { void startApply(); },
},
],
});
@@ -79,13 +116,31 @@ export default function UpdaterPanel() {
return (
<div>
{applying && (
<div className="update-overlay" role="alert" aria-live="assertive">
<div className="update-spinner-wrap">
<div className="update-spinner-ring" />
<div className="update-spinner-center">
<div className="update-spinner-label">{t('updater_applying')}</div>
<div className="update-spinner-pct">{progressPct}%</div>
<div className="update-overlay">
<div className="dialog-surface update-dialog" role="dialog" aria-modal="true" aria-label={t('updater_applying')} tabIndex={-1} ref={modalRef}>
<div className="dialog-stripe" style={{ background: 'rgb(var(--accent))' }} />
<div className="dialog-body">
<div className="dialog-title">{t('updater_applying')}</div>
<div className="update-spinner-wrap" aria-hidden="true">
<div className="update-spinner-ring" />
<div className="update-spinner-center"><div className="update-spinner-pct">{progressPct}%</div></div>
</div>
<div role="status" aria-live="polite">{t(PHASE_KEYS[phase] || 'updater_phase_verify')}</div>
{applyError ? <InfoBar tone="error" dismissible={false}>{applyError}</InfoBar>
: <div className="text-muted">{t('updater_applying_hint')}</div>}
</div>
{applyError && <div className="dialog-actions">
<Button variant="standard" onClick={() => window.location.reload()}>{t('updater_reload')}</Button>
<Button variant="accent" onClick={() => {
const pending = JSON.parse(sessionStorage.getItem('plc_update_pending') || 'null');
if (pending) {
pending.startedAt = Date.now();
sessionStorage.setItem('plc_update_pending', JSON.stringify(pending));
pollUntilReady(pending.targetVersion, pending.startedAt);
}
else { setApplying(false); reload(); }
}}>{t('updater_retry_check')}</Button>
</div>}
</div>
</div>
)}
@@ -102,8 +157,6 @@ export default function UpdaterPanel() {
{status && <span className="text-muted" style={{ fontSize: '.78rem' }}>{t('updater_current_version')} v{status.currentVersion}</span>}
</div>
{applying && <InfoBar tone="warning" dismissible={false}>{t('updater_applying_hint')}</InfoBar>}
{loading && <div className="text-muted">{t('common_loading')}</div>}
{error && <InfoBar tone="error" dismissible={false}>{error}</InfoBar>}