feat: panel de Actualizaciones en Configuracion (solo informativo)

Compara la version instalada contra el CHANGELOG.md de main en Gitea (lectura anonima por HTTPS,
sin token) y muestra cada version pendiente con su criticidad -- derivada del digito que cambio
(1ro=Alta, 2do=Media, 3ro=Baja, sin etiquetar nada a mano, regla ya documentada en
VERSIONADO.md), una descripcion breve, y si trae migraciones de base de datos nuevas. Banner en
rojo avisando que la actualizacion requiere reiniciar el sistema.

No instala nada automaticamente -- solo informa. Aplicar la actualizacion (git pull, build,
migrar, reiniciar servicios) sigue siendo manual, a criterio del administrador de cada
instalacion, tal cual DEPLOY_WEB.md ya documenta.

Nuevo GET /api/updater/status (solo superadmin). Requiere que el repo en Gitea sea publico con
vista anonima habilitada -- documentado en DEPLOY_WEB.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-16 17:11:58 -05:00
parent 95e3a0199d
commit 59aadc6a35
15 changed files with 273 additions and 4 deletions
+10
View File
@@ -119,6 +119,16 @@
- `GET /api/search?q=texto&projectId=1` — acotado a un proyecto: solo `{ activities, tasks }` de - `GET /api/search?q=texto&projectId=1` — acotado a un proyecto: solo `{ activities, tasks }` de
ESE proyecto (sin el dato del proyecto, ya es el contexto). ESE proyecto (sin el dato del proyecto, ya es el contexto).
### Actualizaciones (`/api/updater`) — solo Superadministrador
- `GET /api/updater/status` — compara la `VERSION` local contra `CHANGELOG.md` de `main` en
Gitea (leído de forma anónima por HTTPS, sin token — requiere repo público y vista anónima
habilitada en el servidor de Gitea). `{ currentVersion, upToDate, latestVersion,
overallCriticality?, hasMigrations, pending: [{ version, date, criticality, summary }] }`.
`criticality` se deriva del dígito de versión que cambió (X.Y.Z: 1ro=alta, 2do=media,
3ro=baja), nunca se etiqueta a mano. `hasMigrations` compara los archivos de
`backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica
ningún cambio, la actualización real sigue siendo manual.
### Dashboard (`/api/dashboard`) ### Dashboard (`/api/dashboard`)
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio) - `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite - `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
+14
View File
@@ -1,5 +1,19 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [1.19.0] - 2026-09-16
### Added
- Panel "Actualizaciones" en Configuración (solo superadmin): compara la versión instalada contra
el `CHANGELOG.md` de `main` en Gitea y muestra cada versión pendiente con su criticidad (deriva
del dígito que cambió — 1ro=Alta, 2do=Media, 3ro=Baja, sin etiquetar nada a mano), una
descripción breve, y si trae cambios de base de datos (comparando los archivos de
`backend/database/migrations/` locales contra los de `main`). Banner en rojo, siempre visible
si hay algo pendiente, avisando que la actualización requiere reiniciar el sistema. No instala
nada automáticamente — solo informa; aplicar la actualización sigue siendo manual (SSH), a
criterio del administrador de cada instalación.
- Nuevo endpoint `GET /api/updater/status` (solo superadmin). Lee el repo de Gitea de forma
anónima por HTTPS — no requiere ningún token ni credencial en el server instalado, solo que el
repo sea público y el servidor de Gitea permita vista anónima.
## [1.18.0] - 2026-09-16 ## [1.18.0] - 2026-09-16
### Added ### Added
- Buscador de Proyectos ahora también busca en actividades y tareas de cualquier proyecto, no - Buscador de Proyectos ahora también busca en actividades y tareas de cualquier proyecto, no
+6
View File
@@ -26,6 +26,12 @@ hace falta `create-admin.js` en instalaciones nuevas.
Las secciones de abajo son la referencia manual (lo que hace el script paso a paso) — útiles para Las secciones de abajo son la referencia manual (lo que hace el script paso a paso) — útiles para
entender el porqué de cada cosa o para resolver un problema puntual sin volver a correr todo. entender el porqué de cada cosa o para resolver un problema puntual sin volver a correr todo.
**Requisito para el panel "Actualizaciones"** (Configuración → superadmin): el servidor instalado
necesita poder llegar por HTTPS a `gitea.linkedcorp.net` de forma anónima (repo público + vista
anónima habilitada en Gitea) para poder consultar el `CHANGELOG.md` y las migraciones nuevas. No
requiere ningún token ni credencial. Si no hay conectividad a Gitea, ese panel simplemente muestra
el error al buscar actualizaciones — el resto de la app funciona igual.
--- ---
## Primer despliegue en producción (ya ejecutado — referencia) ## Primer despliegue en producción (ya ejecutado — referencia)
+1 -1
View File
@@ -1 +1 @@
1.18.0 1.19.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "1.18.0", "version": "1.19.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+2
View File
@@ -20,6 +20,7 @@ import settingsRoutes from './modules/settings/routes.js';
import companyRoutes from './modules/company/routes.js'; import companyRoutes from './modules/company/routes.js';
import recurringTasksRoutes from './modules/recurring-tasks/routes.js'; import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
import searchRoutes from './modules/search/routes.js'; import searchRoutes from './modules/search/routes.js';
import updaterRoutes from './modules/updater/routes.js';
export const app = express(); export const app = express();
@@ -44,6 +45,7 @@ app.use('/api/settings', settingsRoutes);
app.use('/api/company', companyRoutes); app.use('/api/company', companyRoutes);
app.use('/api/recurring-tasks', recurringTasksRoutes); app.use('/api/recurring-tasks', recurringTasksRoutes);
app.use('/api/search', searchRoutes); app.use('/api/search', searchRoutes);
app.use('/api/updater', updaterRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica). // especifico registrado despues (ver ESTADO.md, deuda tecnica).
@@ -0,0 +1,6 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getUpdateStatus = asyncHandler(async (req, res) => {
ok(res, await service.checkForUpdates());
});
+26
View File
@@ -0,0 +1,26 @@
// Lee el repo en Gitea de forma anonima (repo publico + vista anonima habilitada en el servidor
// de Gitea) — el servidor de un cliente instalado en otro lado NO necesita ningun token ni
// credencial para esto, solo poder llegar por HTTPS a Gitea.
const GITEA_BASE = process.env.GITEA_BASE_URL || 'http://gitea.linkedcorp.net';
const GITEA_OWNER = process.env.GITEA_OWNER || 'mgonzalez';
const GITEA_REPO = process.env.GITEA_REPO || 'project.linkedcorp.net';
const BRANCH = 'main';
async function getRaw(path) {
const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/raw/${path}?ref=${BRANCH}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Gitea respondio ${res.status} al leer ${path}`);
return res.text();
}
export async function fetchChangelog() {
return getRaw('CHANGELOG.md');
}
export async function fetchRemoteMigrationNames() {
const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/contents/backend/database/migrations?ref=${BRANCH}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Gitea respondio ${res.status} al listar migraciones`);
const entries = await res.json();
return entries.filter((e) => e.type === 'file').map((e) => e.name);
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
import { getUpdateStatus } from './controller.js';
const router = Router();
router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
router.get('/status', getUpdateStatus);
export default router;
+102
View File
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as repo from './repository.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations');
function parseVersion(v) {
const [major, minor, patch] = v.trim().split('.').map(Number);
return { major, minor, patch };
}
function compareVersions(a, b) {
const va = parseVersion(a);
const vb = parseVersion(b);
if (va.major !== vb.major) return va.major - vb.major;
if (va.minor !== vb.minor) return va.minor - vb.minor;
return va.patch - vb.patch;
}
// Regla del proyecto (VERSIONADO.md): cambia el 1er digito = alta, 2do = media, 3er = baja.
// Sin etiquetar nada a mano — se deriva solo de que numero cambio entre las dos versiones.
function classifyBump(fromVersion, toVersion) {
const a = parseVersion(fromVersion);
const b = parseVersion(toVersion);
if (b.major !== a.major) return 'alta';
if (b.minor !== a.minor) return 'media';
return 'baja';
}
// Se parte el texto en la propia delimitacion "## [" (en vez de una sola regex con lookahead de
// fin-de-texto): con la bandera `m`, `$` matchea fin de CUALQUIER linea, asi que un lookahead
// `\s*$` para "fin del changelog completo" en realidad cortaba en la primera linea en blanco
// (el cuerpo quedaba vacio siempre). Partiendo por bloque no hay esa ambiguedad.
function parseChangelog(text) {
const entries = [];
const blocks = text.split(/\n(?=## \[)/);
for (const block of blocks) {
const match = block.match(/^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*\n([\s\S]*)$/);
if (match) entries.push({ version: match[1], date: match[2], body: match[3].trim() });
}
return entries;
}
// Resumen corto: saca los encabezados "### Algo" y las vinietas "- ", deja solo el texto, truncado.
function summarize(body, maxLength = 260) {
const lines = body
.split('\n')
.map((l) => l.trim().replace(/^-\s+/, ''))
.filter((l) => l && !l.startsWith('###'));
const joined = lines.join(' ').replace(/\s+/g, ' ').trim();
return joined.length > maxLength ? `${joined.slice(0, maxLength).trim()}…` : joined;
}
function getLocalVersion() {
return fs.readFileSync(VERSION_FILE, 'utf8').trim();
}
function getLocalMigrationNames() {
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
}
export async function checkForUpdates() {
const currentVersion = getLocalVersion();
const changelogText = await repo.fetchChangelog();
const allEntries = parseChangelog(changelogText);
const pending = allEntries
.filter((e) => compareVersions(e.version, currentVersion) > 0)
.sort((a, b) => compareVersions(a.version, b.version));
if (pending.length === 0) {
return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false };
}
let previousInChain = currentVersion;
const pendingWithMeta = pending.map((e) => {
const criticality = classifyBump(previousInChain, e.version);
previousInChain = e.version;
return { version: e.version, date: e.date, criticality, summary: summarize(e.body) };
});
const localMigrations = new Set(getLocalMigrationNames());
const remoteMigrations = await repo.fetchRemoteMigrationNames();
const hasMigrations = remoteMigrations.some((name) => !localMigrations.has(name));
const latestVersion = pending[pending.length - 1].version;
const overallCriticality = pendingWithMeta.some((e) => e.criticality === 'alta')
? 'alta'
: pendingWithMeta.some((e) => e.criticality === 'media') ? 'media' : 'baja';
return {
currentVersion,
upToDate: false,
latestVersion,
overallCriticality,
hasMigrations,
pending: pendingWithMeta,
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "1.18.0", "version": "1.19.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+24
View File
@@ -70,6 +70,18 @@ export const messages = {
smtp_test_send: 'Enviar prueba', smtp_test_send: 'Enviar prueba',
smtp_test_sending: 'Enviando...', smtp_test_sending: 'Enviando...',
smtp_test_success: 'Correo de prueba enviado correctamente a', smtp_test_success: 'Correo de prueba enviado correctamente a',
config_updater: 'Actualizaciones',
updater_check: 'Buscar actualizaciones',
updater_current_version: 'Versión instalada:',
updater_up_to_date: 'Estás en la última versión disponible.',
updater_available: 'Actualización disponible',
updater_restart_required: 'Esta actualización requiere reiniciar el sistema — coordina con tu administrador antes de aplicarla.',
updater_has_migrations: 'Incluye cambios en la base de datos (migraciones nuevas).',
updater_criticality_alta: 'Alta',
updater_criticality_media: 'Media',
updater_criticality_baja: 'Baja',
updater_rollback_hint: 'Antes de actualizar, respalda la base de datos. Si algo sale mal, restaura ese respaldo y volvé al commit anterior a esta actualización en el historial de Gitea.',
password_show: 'Mostrar clave', password_show: 'Mostrar clave',
password_hide: 'Ocultar clave', password_hide: 'Ocultar clave',
common_new: 'Nuevo', common_new: 'Nuevo',
@@ -318,6 +330,18 @@ export const messages = {
smtp_test_send: 'Send test', smtp_test_send: 'Send test',
smtp_test_sending: 'Sending...', smtp_test_sending: 'Sending...',
smtp_test_success: 'Test email sent successfully to', smtp_test_success: 'Test email sent successfully to',
config_updater: 'Updates',
updater_check: 'Check for updates',
updater_current_version: 'Installed version:',
updater_up_to_date: 'You are on the latest available version.',
updater_available: 'Update available',
updater_restart_required: 'This update requires restarting the system — coordinate with your administrator before applying it.',
updater_has_migrations: 'Includes database changes (new migrations).',
updater_criticality_alta: 'High',
updater_criticality_media: 'Medium',
updater_criticality_baja: 'Low',
updater_rollback_hint: 'Back up the database before updating. If something goes wrong, restore that backup and roll back to the commit before this update in the Gitea history.',
password_show: 'Show password', password_show: 'Show password',
password_hide: 'Hide password', password_hide: 'Hide password',
common_new: 'New', common_new: 'New',
@@ -1,5 +1,5 @@
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2 } from 'lucide-react'; import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx'; import { useAuth } from '../../context/auth-context.jsx';
import { Expander } from '../../components/Expander.jsx'; import { Expander } from '../../components/Expander.jsx';
@@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { CatalogEditor } from './CatalogEditor.jsx'; import { CatalogEditor } from './CatalogEditor.jsx';
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx'; import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
import CompanySettingsPanel from './CompanySettingsPanel.jsx'; import CompanySettingsPanel from './CompanySettingsPanel.jsx';
import UpdaterPanel from './UpdaterPanel.jsx';
export default function ConfiguracionPage() { export default function ConfiguracionPage() {
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi(); const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
@@ -101,6 +102,10 @@ export default function ConfiguracionPage() {
<Expander icon={Mail} title={t('config_smtp')}> <Expander icon={Mail} title={t('config_smtp')}>
<SmtpSettingsPanel /> <SmtpSettingsPanel />
</Expander> </Expander>
<Expander icon={RefreshCw} title={t('config_updater')}>
<UpdaterPanel />
</Expander>
</div> </div>
); );
} }
@@ -0,0 +1,61 @@
import { RefreshCw } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { getUpdateStatus } 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' };
export default function UpdaterPanel() {
const { t } = useUi();
const { data: status, loading, error, reload } = useAsync(getUpdateStatus, []);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<Button variant="standard" icon={RefreshCw} onClick={reload} disabled={loading}>
{t('updater_check')}
</Button>
{status && <span className="text-muted" style={{ fontSize: '.78rem' }}>{t('updater_current_version')} v{status.currentVersion}</span>}
</div>
{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.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>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { api } from './api.js';
export const getUpdateStatus = () => api.get('/updater/status');