diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e4f29..2fd6fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ESTADO.md b/ESTADO.md index 301e948..b185dc9 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -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` diff --git a/VERSION b/VERSION index 4a36342..cb2b00e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 +3.0.1 diff --git a/backend/package-lock.json b/backend/package-lock.json index 06aa3e0..9925b02 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index ae02a25..4a53c38 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "3.0.0", + "version": "3.0.1", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/updater/controller.js b/backend/src/modules/updater/controller.js index f86c5f9..0cf0cde 100644 --- a/backend/src/modules/updater/controller.js +++ b/backend/src/modules/updater/controller.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)); }); diff --git a/backend/src/modules/updater/routes.js b/backend/src/modules/updater/routes.js index 56b52b2..3b8d960 100644 --- a/backend/src/modules/updater/routes.js +++ b/backend/src/modules/updater/routes.js @@ -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; diff --git a/backend/src/modules/updater/service.js b/backend/src/modules/updater/service.js index 69a40de..f319f39 100644 --- a/backend/src/modules/updater/service.js +++ b/backend/src/modules/updater/service.js @@ -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(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c289643..74784a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b69952d..3c4331a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "3.0.0", + "version": "3.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/serve.json b/frontend/public/serve.json index c975545..8507906 100644 --- a/frontend/public/serve.json +++ b/frontend/public/serve.json @@ -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" }] diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index c0d0574..e12512c 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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:', diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index c7453ab..94fd2b6 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( diff --git a/frontend/src/pages/Configuracion/UpdaterPanel.jsx b/frontend/src/pages/Configuracion/UpdaterPanel.jsx index 2cd7391..9ca9d38 100644 --- a/frontend/src/pages/Configuracion/UpdaterPanel.jsx +++ b/frontend/src/pages/Configuracion/UpdaterPanel.jsx @@ -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 (
{applying && ( -
-
-
-
-
{t('updater_applying')}
-
{progressPct}%
+
+
+
+
+
{t('updater_applying')}
+ + {applyError &&
+ + +
}
)} @@ -102,8 +157,6 @@ export default function UpdaterPanel() { {status && {t('updater_current_version')} v{status.currentVersion}}
- {applying && {t('updater_applying_hint')}} - {loading &&
{t('common_loading')}
} {error && {error}} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index f3ee463..f76ca1f 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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 }), diff --git a/frontend/src/services/serviceWorker.js b/frontend/src/services/serviceWorker.js new file mode 100644 index 0000000..f3a1527 --- /dev/null +++ b/frontend/src/services/serviceWorker.js @@ -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); +} diff --git a/frontend/src/services/updater.js b/frontend/src/services/updater.js index d2b4750..dbea104 100644 --- a/frontend/src/services/updater.js +++ b/frontend/src/services/updater.js @@ -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; +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index b08ad8d..e3fa47f 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2657743..4ddd5ac 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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 diff --git a/scripts/apply-update.sh b/scripts/apply-update.sh index d1a04d4..8830c70 100755 --- a/scripts/apply-update.sh +++ b/scripts/apply-update.sh @@ -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 " 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 ==="