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 (
{applying && (
{t('updater_applying')}
{progressPct}%
)}
{status && !status.upToDate && status.applyAvailable && ( )} {status && {t('updater_current_version')} v{status.currentVersion}}
{applying && {t('updater_applying_hint')}} {loading &&
{t('common_loading')}
} {error && {error}} {status && status.upToDate && ( {t('updater_up_to_date')} )} {status && !status.upToDate && (
{t('updater_restart_required')} {!status.applyAvailable && ( {t('updater_apply_not_configured')} )} {status.hasMigrations && ( {t('updater_has_migrations')} )}

{t('updater_available')} — v{status.currentVersion} → v{status.latestVersion} {' '} {t(`updater_criticality_${status.overallCriticality}`)}

{status.pending.map((entry) => (
v{entry.version} {entry.date} {t(`updater_criticality_${entry.criticality}`)}
{entry.summary}
))}
{t('updater_rollback_hint')}
)}
); }