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:
@@ -93,6 +93,16 @@ export const messages = {
|
||||
updater_apply_started: 'Actualización iniciada. El sistema se reiniciará en unos minutos.',
|
||||
updater_applying_hint: 'Actualización en curso — no cierres esta pestaña. El sistema se reiniciará solo al terminar.',
|
||||
updater_apply_timeout: 'La actualización está tardando más de lo esperado. Revisa el estado en unos minutos o verifica el servidor.',
|
||||
updater_apply_failed: 'La actualización falló. Revisa el registro del actualizador en el servidor.',
|
||||
updater_phase_backup: 'Respaldando la base de datos…',
|
||||
updater_phase_source: 'Descargando el código nuevo…',
|
||||
updater_phase_backend: 'Instalando backend y aplicando migraciones…',
|
||||
updater_phase_frontend: 'Instalando y compilando frontend…',
|
||||
updater_phase_publish: 'Publicando frontend…',
|
||||
updater_phase_restart: 'Reiniciando servicios…',
|
||||
updater_phase_verify: 'Comprobando ambas versiones…',
|
||||
updater_retry_check: 'Reintentar comprobación',
|
||||
updater_reload: 'Recargar página',
|
||||
updater_apply_not_configured: 'La auto-actualización no está habilitada en este servidor. Pide a tu administrador que aplique esta actualización por SSH.',
|
||||
updater_login_popup_title: 'Hay una actualización pendiente',
|
||||
updater_login_popup_msg: 'El sistema tiene una versión nueva disponible:',
|
||||
@@ -602,6 +612,16 @@ export const messages = {
|
||||
updater_apply_started: 'Update started. The system will restart in a few minutes.',
|
||||
updater_applying_hint: 'Update in progress — do not close this tab. The system will restart on its own when done.',
|
||||
updater_apply_timeout: 'The update is taking longer than expected. Check back in a few minutes or verify the server.',
|
||||
updater_apply_failed: 'The update failed. Check the updater log on the server.',
|
||||
updater_phase_backup: 'Backing up the database…',
|
||||
updater_phase_source: 'Downloading the new code…',
|
||||
updater_phase_backend: 'Installing backend and applying migrations…',
|
||||
updater_phase_frontend: 'Installing and building frontend…',
|
||||
updater_phase_publish: 'Publishing frontend…',
|
||||
updater_phase_restart: 'Restarting services…',
|
||||
updater_phase_verify: 'Checking both versions…',
|
||||
updater_retry_check: 'Retry check',
|
||||
updater_reload: 'Reload page',
|
||||
updater_apply_not_configured: 'Auto-update is not enabled on this server. Ask your administrator to apply this update via SSH.',
|
||||
updater_login_popup_title: 'There is a pending update',
|
||||
updater_login_popup_msg: 'A new version of the system is available:',
|
||||
|
||||
+2
-27
@@ -1,38 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { registerAppServiceWorker } from './services/serviceWorker.js';
|
||||
import App from './app/App.jsx';
|
||||
import { installGlobalErrorReporter } from './utils/errorReporter.js';
|
||||
import './styles/tokens.css';
|
||||
import './styles/app.css';
|
||||
|
||||
installGlobalErrorReporter();
|
||||
// autoUpdate: toma la version nueva del service worker sola en cuanto esta lista, sin pedirle
|
||||
// nada al usuario (no hay riesgo de mostrar una version vieja de la UI por mucho tiempo).
|
||||
registerSW({
|
||||
immediate: true,
|
||||
// Ademas de al cargar, revisa si hay version nueva cada 30 min y al volver a la pestaña --
|
||||
// asi una pestaña abierta todo el dia no se queda en la version anterior.
|
||||
onRegisteredSW(_url, registration) {
|
||||
if (!registration) return;
|
||||
const check = () => registration.update().catch(() => {});
|
||||
setInterval(check, 30 * 60 * 1000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
|
||||
},
|
||||
});
|
||||
|
||||
// skipWaiting+clientsClaim (activados por autoUpdate) hacen que el service worker nuevo tome
|
||||
// control solo, pero eso NO refresca la pagina ya abierta -- el usuario seguia viendo JS/CSS
|
||||
// viejo hasta un F5 manual. Con esto, en cuanto el nuevo SW toma control, se refresca sola una
|
||||
// vez (la bandera evita un loop si el evento dispara mas de una vez).
|
||||
if ('serviceWorker' in navigator) {
|
||||
let reloaded = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloaded) return;
|
||||
reloaded = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
registerAppServiceWorker();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ function getToken() {
|
||||
return localStorage.getItem('plc_token');
|
||||
}
|
||||
|
||||
async function request(method, path, { body, isForm, extraHeaders } = {}) {
|
||||
async function request(method, path, { body, isForm, extraHeaders, signal } = {}) {
|
||||
const headers = { ...extraHeaders };
|
||||
const token = getToken();
|
||||
// Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion.
|
||||
@@ -26,6 +26,7 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
|
||||
method,
|
||||
headers,
|
||||
body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
// El servidor renueva el token de desbloqueo de Contraseñas en cada request valido (ver vault/middleware.js).
|
||||
@@ -51,7 +52,7 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path, extraHeaders) => request('GET', path, { extraHeaders }),
|
||||
get: (path, extraHeaders, options) => request('GET', path, { extraHeaders, ...options }),
|
||||
post: (path, body, extraHeaders) => request('POST', path, { body, extraHeaders }),
|
||||
put: (path, body, extraHeaders) => request('PUT', path, { body, extraHeaders }),
|
||||
patch: (path, body) => request('PATCH', path, { body }),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
let updateSW;
|
||||
let suppressControllerReload = false;
|
||||
|
||||
export function registerAppServiceWorker() {
|
||||
updateSW = registerSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(_url, registration) {
|
||||
if (!registration) return;
|
||||
const check = () => registration.update().catch(() => {});
|
||||
setInterval(check, 30 * 60 * 1000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
|
||||
},
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
let reloaded = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloaded || suppressControllerReload || sessionStorage.getItem('plc_update_pending')) return;
|
||||
reloaded = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateUpdateAndReload() {
|
||||
suppressControllerReload = true;
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
await registration?.update();
|
||||
await updateSW?.(true); // skipWaiting de vite-plugin-pwa
|
||||
} catch {
|
||||
// Una navegación de red con cache-busting sigue siendo posible sin SW.
|
||||
}
|
||||
}
|
||||
sessionStorage.removeItem('plc_update_pending');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('_updated', Date.now().toString());
|
||||
window.location.replace(url.href);
|
||||
}
|
||||
@@ -2,3 +2,12 @@ import { api } from './api.js';
|
||||
|
||||
export const getUpdateStatus = () => api.get('/updater/status');
|
||||
export const applyUpdate = () => api.post('/updater/apply');
|
||||
export const getApplyProgress = () => api.get('/updater/progress', undefined, { signal: AbortSignal.timeout(8000) });
|
||||
|
||||
export async function getFrontendVersion() {
|
||||
const response = await fetch(`/version.json?update=${Date.now()}`, {
|
||||
cache: 'no-store', signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Frontend HTTP ${response.status}`);
|
||||
return (await response.json()).version;
|
||||
}
|
||||
|
||||
@@ -514,17 +514,19 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
adonde ir. z-index por encima de dialog-overlay/detail-popup-overlay a proposito. */
|
||||
.update-overlay {
|
||||
position: fixed; inset: 0; background: rgb(0 0 0 / .7); z-index: 300;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
.update-dialog { width: min(100%, 420px); text-align: center; }
|
||||
.update-dialog .dialog-body { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.update-spinner-wrap { position: relative; width: 150px; height: 150px; flex: none; }
|
||||
.update-spinner-ring {
|
||||
position: absolute; inset: 0; border-radius: 999px;
|
||||
border: 6px solid rgb(255 255 255 / .2); border-top-color: rgb(var(--accent));
|
||||
border: 6px solid rgb(var(--line)); border-top-color: rgb(var(--accent));
|
||||
animation: update-spin 0.9s linear infinite;
|
||||
}
|
||||
.update-spinner-center {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column; gap: 2px;
|
||||
align-items: center; justify-content: center; text-align: center; color: #fff;
|
||||
align-items: center; justify-content: center; text-align: center; color: rgb(var(--body));
|
||||
}
|
||||
.update-spinner-label { font-size: .78rem; font-weight: 600; opacity: .85; }
|
||||
.update-spinner-pct { font-size: 1.7rem; font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user