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,5 +1,13 @@
|
||||
# Changelog - project.linkedcorp.net
|
||||
|
||||
## [3.0.1] - 2026-10-23
|
||||
### Fixed
|
||||
- El diálogo de actualización permanece abierto con la fase del proceso y solo recarga al confirmar
|
||||
que el script terminó y que backend y frontend sirven la versión nueva. El frontend expone
|
||||
`version.json` sin caché; se fuerza la actualización del Service Worker antes de recargar.
|
||||
- Se toleran los cortes durante reinicios y se muestra error o espera agotada tras 10 minutos,
|
||||
con opción de reintentar la comprobación o recargar.
|
||||
|
||||
## [3.0.0] - 2026-10-23
|
||||
### Security
|
||||
- Se restringe la asignación de superadmin, la escritura de consultores y la búsqueda/tareas
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ESTADO — project.linkedcorp.net
|
||||
|
||||
Ultima actualizacion: 2026-10-23 (v3.0.0 en desarrollo, sin desplegar)
|
||||
Ultima actualizacion: 2026-10-23 (v3.0.1 en desarrollo, sin desplegar)
|
||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
||||
autorregistro/aprobacion/SMTP.
|
||||
|
||||
@@ -25,6 +25,10 @@ autorregistro/aprobacion/SMTP.
|
||||
|
||||
## Como retomar
|
||||
|
||||
Actualizador v3.0.1: el script publica `logs/update.status`; el panel espera su fin y comprueba
|
||||
las versiones activas de backend y `version.json` del frontend antes de activar el Service Worker
|
||||
y recargar. Pendiente verificar el flujo completo en un despliegue autorizado; no se tocó producción.
|
||||
|
||||
1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`.
|
||||
2. Leer `AGENTS.md`, `SPEC.md`, `PRODUCCION.md` y este archivo.
|
||||
3. Backend dev: `cd backend && npm install && npm run dev` (puerto 4019). Requiere `backend/.env`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -5,6 +5,11 @@ export const getUpdateStatus = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.checkForUpdates());
|
||||
});
|
||||
|
||||
export const getApplyProgress = asyncHandler(async (req, res) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
ok(res, service.getApplyProgress());
|
||||
});
|
||||
|
||||
export const applyUpdate = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.applyUpdate(req.currentUser.id));
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
|
||||
import { getUpdateStatus, applyUpdate } from './controller.js';
|
||||
import { getUpdateStatus, getApplyProgress, applyUpdate } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
|
||||
router.get('/status', getUpdateStatus);
|
||||
router.get('/progress', getApplyProgress);
|
||||
router.post('/apply', applyUpdate);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { logAudit } from '../../utils/audit.js';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
|
||||
const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations');
|
||||
// Fijada al arrancar: leer VERSION en cada request adelanta falsamente el fin tras git reset.
|
||||
const runningVersion = fs.readFileSync(VERSION_FILE, 'utf8').trim();
|
||||
|
||||
function parseVersion(v) {
|
||||
const [major, minor, patch] = v.trim().split('.').map(Number);
|
||||
@@ -70,6 +72,21 @@ function isApplyAvailable() {
|
||||
return !!(env.update.appDir && env.update.backendService && env.update.frontendService);
|
||||
}
|
||||
|
||||
export function getApplyProgress() {
|
||||
let state = 'idle';
|
||||
let phase = 'idle';
|
||||
let targetVersion = null;
|
||||
if (isApplyAvailable()) {
|
||||
try {
|
||||
const fields = fs.readFileSync(path.join(env.update.appDir, 'logs', 'update.status'), 'utf8').trim().split('|');
|
||||
if (fields.length === 3) [state, phase, targetVersion] = fields;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return { runningVersion, state, phase, targetVersion };
|
||||
}
|
||||
|
||||
export async function checkForUpdates() {
|
||||
const currentVersion = getLocalVersion();
|
||||
const changelogText = await repo.fetchChangelog();
|
||||
@@ -128,8 +145,10 @@ export async function applyUpdate(actorId) {
|
||||
throw new HttpError(409, 'Ya hay una actualizacion en curso');
|
||||
}
|
||||
|
||||
applyInProgress = true;
|
||||
const scriptPath = path.join(env.update.appDir, 'scripts', 'apply-update.sh');
|
||||
// La copia externa debe corresponder al código actual antes de hacer git reset en source/.
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../../scripts/apply-update.sh'), scriptPath);
|
||||
applyInProgress = true;
|
||||
const child = spawn('bash', [scriptPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
@@ -138,6 +157,7 @@ export async function applyUpdate(actorId) {
|
||||
APP_DIR: env.update.appDir,
|
||||
BACKEND_SERVICE: env.update.backendService,
|
||||
FRONTEND_SERVICE: env.update.frontendService,
|
||||
TARGET_VERSION: status.latestVersion,
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.16",
|
||||
"highlight.js": "^11.12.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"source": "@(sw.js|registerSW.js|workbox-*.js)",
|
||||
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
|
||||
},
|
||||
{
|
||||
"source": "version.json",
|
||||
"headers": [{ "key": "Cache-Control", "value": "no-store" }]
|
||||
},
|
||||
{
|
||||
"source": "assets/**",
|
||||
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'frontend-version',
|
||||
generateBundle() {
|
||||
this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version }) });
|
||||
},
|
||||
},
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'favicon.png', 'apple-touch-icon.png'],
|
||||
@@ -25,6 +34,7 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globIgnores: ['**/version.json'],
|
||||
// El service worker nunca debe interceptar /api — siempre a la red, nunca cache de
|
||||
// respuestas viejas (datos de proyectos/actividades desactualizados seria peor que no
|
||||
// tener PWA). navigateFallback (para que recargar una ruta como /proyectos/5 sirva el
|
||||
|
||||
@@ -19,11 +19,20 @@ set -euo pipefail
|
||||
: "${APP_DIR:?falta APP_DIR}"
|
||||
: "${BACKEND_SERVICE:?falta BACKEND_SERVICE}"
|
||||
: "${FRONTEND_SERVICE:?falta FRONTEND_SERVICE}"
|
||||
: "${TARGET_VERSION:?falta TARGET_VERSION}"
|
||||
|
||||
LOG="$APP_DIR/logs/update.log"
|
||||
mkdir -p "$APP_DIR/logs" "$APP_DIR/backups"
|
||||
exec >>"$LOG" 2>&1
|
||||
|
||||
PHASE=backup
|
||||
write_status() {
|
||||
printf '%s|%s|%s\n' "$1" "$PHASE" "$TARGET_VERSION" > "$APP_DIR/logs/update.status.tmp"
|
||||
mv "$APP_DIR/logs/update.status.tmp" "$APP_DIR/logs/update.status"
|
||||
}
|
||||
trap 'write_status failed' ERR
|
||||
write_status running
|
||||
|
||||
echo ""
|
||||
echo "=== update start $(date -Iseconds) ==="
|
||||
|
||||
@@ -32,18 +41,25 @@ mysqldump --single-transaction -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB
|
||||
> "$APP_DIR/backups/pre_update_$(date +%Y%m%d_%H%M%S).sql"
|
||||
|
||||
echo "-- git fetch + reset --"
|
||||
PHASE=source
|
||||
write_status running
|
||||
cd "$APP_DIR/source"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
NEW_VERSION="$(cat VERSION)"
|
||||
echo "version objetivo: $NEW_VERSION"
|
||||
[[ "$NEW_VERSION" == "$TARGET_VERSION" ]] || { echo "version remota distinta de la solicitada: $TARGET_VERSION"; false; }
|
||||
|
||||
echo "-- backend: npm install + migraciones --"
|
||||
PHASE=backend
|
||||
write_status running
|
||||
cd "$APP_DIR/source/backend"
|
||||
npm install --omit=dev
|
||||
node src/scripts/migrate-db.js
|
||||
|
||||
echo "-- frontend: npm install + build --"
|
||||
PHASE=frontend
|
||||
write_status running
|
||||
cd "$APP_DIR/source/frontend"
|
||||
# --include=dev es necesario a la fuerza: este script hereda NODE_ENV=production del backend
|
||||
# (via EnvironmentFile), y con eso npm salta devDependencies por defecto -- incluido vite, que
|
||||
@@ -53,10 +69,14 @@ npm install --include=dev
|
||||
npm run build
|
||||
|
||||
echo "-- publicando build de frontend --"
|
||||
PHASE=publish
|
||||
write_status running
|
||||
rm -rf "$APP_DIR/frontend/dist"
|
||||
cp -r dist "$APP_DIR/frontend/dist"
|
||||
|
||||
echo "-- reiniciando servicios --"
|
||||
PHASE=restart
|
||||
write_status running
|
||||
# Dos llamadas separadas a proposito: el sudoers NOPASSWD (ver DEPLOY_WEB.md) whitelistea cada
|
||||
# "systemctl restart <servicio>" por separado -- un solo "systemctl restart back front" junto no
|
||||
# matchea ninguna de las dos reglas, sudo pide contraseña, falla en este contexto sin terminal, y
|
||||
@@ -65,4 +85,6 @@ echo "-- reiniciando servicios --"
|
||||
sudo systemctl restart "$BACKEND_SERVICE"
|
||||
sudo systemctl restart "$FRONTEND_SERVICE"
|
||||
|
||||
PHASE=complete
|
||||
write_status done
|
||||
echo "=== update done $(date -Iseconds) -- v$NEW_VERSION ==="
|
||||
|
||||
Reference in New Issue
Block a user