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:
@@ -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>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user