d5f3f05470
Agrega un overlay de pantalla completa (circulo girando + porcentaje estimado + "Actualizando...") que bloquea todo clic mientras corre la auto-actualizacion, y recarga la pagina sola al terminar. De paso corrige un bug real: el useEffect de mountedRef no reactivaba el flag en su setup, asi que con React.StrictMode (dev) el polling de estado nunca llegaba a correr -- no afectaba produccion (StrictMode no duplica efectos ahi) pero ocultaba el problema al probar en dev. Tambien se corrigen un par de voseos que quedaron en los textos de Actualizaciones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
148 lines
6.5 KiB
React
148 lines
6.5 KiB
React
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 { 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
|
|
|
|
export default function UpdaterPanel() {
|
|
const { t, openDialog, pushToast } = useUi();
|
|
const { data: status, loading, error, reload, setData } = useAsync(getUpdateStatus, []);
|
|
const runAction = useAction();
|
|
const [applying, setApplying] = useState(false);
|
|
const [progressPct, setProgressPct] = useState(0);
|
|
const mountedRef = useRef(true);
|
|
// 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.
|
|
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();
|
|
if (!mountedRef.current) return;
|
|
setData(result);
|
|
if (result.upToDate) {
|
|
setProgressPct(100);
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
} catch {
|
|
// backend reiniciandose -- se ignora y se reintenta en el proximo ciclo
|
|
}
|
|
}
|
|
if (mountedRef.current) {
|
|
setApplying(false);
|
|
pushToast({ tone: 'warning', message: t('updater_apply_timeout') });
|
|
}
|
|
}
|
|
|
|
function confirmApply() {
|
|
openDialog({
|
|
tone: 'warning',
|
|
title: t('updater_apply_confirm_title'),
|
|
message: `${t('updater_apply_confirm_msg')} v${status.currentVersion} → v${status.latestVersion}`,
|
|
actions: [
|
|
{ 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)),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
<Button variant="standard" icon={RefreshCw} onClick={reload} disabled={loading || applying}>
|
|
{t('updater_check')}
|
|
</Button>
|
|
{status && !status.upToDate && status.applyAvailable && (
|
|
<Button variant="accent" icon={Download} onClick={confirmApply} disabled={applying}>
|
|
{applying ? t('updater_applying') : t('updater_apply')}
|
|
</Button>
|
|
)}
|
|
{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>}
|
|
|
|
{status && status.upToDate && (
|
|
<InfoBar tone="success" dismissible={false}>{t('updater_up_to_date')}</InfoBar>
|
|
)}
|
|
|
|
{status && !status.upToDate && (
|
|
<div>
|
|
<InfoBar tone="error" dismissible={false}>{t('updater_restart_required')}</InfoBar>
|
|
{!status.applyAvailable && (
|
|
<InfoBar tone="warning" dismissible={false}>{t('updater_apply_not_configured')}</InfoBar>
|
|
)}
|
|
{status.hasMigrations && (
|
|
<InfoBar tone="warning" dismissible={false}>{t('updater_has_migrations')}</InfoBar>
|
|
)}
|
|
|
|
<div className="panel-card">
|
|
<h4 style={{ marginTop: 0 }}>
|
|
{t('updater_available')} — v{status.currentVersion} → v{status.latestVersion}
|
|
{' '}
|
|
<Badge color={CRITICALITY_COLOR[status.overallCriticality]}>{t(`updater_criticality_${status.overallCriticality}`)}</Badge>
|
|
</h4>
|
|
{status.pending.map((entry) => (
|
|
<div key={entry.version} style={{ padding: '10px 0', borderTop: '1px solid rgb(var(--line))' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
<strong>v{entry.version}</strong>
|
|
<span className="text-muted" style={{ fontSize: '.72rem' }}>{entry.date}</span>
|
|
<Badge color={CRITICALITY_COLOR[entry.criticality]}>{t(`updater_criticality_${entry.criticality}`)}</Badge>
|
|
</div>
|
|
<div style={{ fontSize: '.8rem', color: 'rgb(var(--muted))' }}>{entry.summary}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="text-muted" style={{ fontSize: '.76rem' }}>{t('updater_rollback_hint')}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|