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:
@@ -119,6 +119,16 @@
|
||||
- `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).
|
||||
|
||||
### 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`)
|
||||
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
|
||||
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 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
|
||||
### Added
|
||||
- Buscador de Proyectos ahora también busca en actividades y tareas de cualquier proyecto, no
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -20,6 +20,7 @@ import settingsRoutes from './modules/settings/routes.js';
|
||||
import companyRoutes from './modules/company/routes.js';
|
||||
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
|
||||
import searchRoutes from './modules/search/routes.js';
|
||||
import updaterRoutes from './modules/updater/routes.js';
|
||||
|
||||
export const app = express();
|
||||
|
||||
@@ -44,6 +45,7 @@ app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/company', companyRoutes);
|
||||
app.use('/api/recurring-tasks', recurringTasksRoutes);
|
||||
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
|
||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||
// 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());
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "1.18.0",
|
||||
"version": "1.19.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -70,6 +70,18 @@ export const messages = {
|
||||
smtp_test_send: 'Enviar prueba',
|
||||
smtp_test_sending: 'Enviando...',
|
||||
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_hide: 'Ocultar clave',
|
||||
common_new: 'Nuevo',
|
||||
@@ -318,6 +330,18 @@ export const messages = {
|
||||
smtp_test_send: 'Send test',
|
||||
smtp_test_sending: 'Sending...',
|
||||
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_hide: 'Hide password',
|
||||
common_new: 'New',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from '../../context/auth-context.jsx';
|
||||
import { Expander } from '../../components/Expander.jsx';
|
||||
@@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { CatalogEditor } from './CatalogEditor.jsx';
|
||||
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
|
||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||
@@ -101,6 +102,10 @@ export default function ConfiguracionPage() {
|
||||
<Expander icon={Mail} title={t('config_smtp')}>
|
||||
<SmtpSettingsPanel />
|
||||
</Expander>
|
||||
|
||||
<Expander icon={RefreshCw} title={t('config_updater')}>
|
||||
<UpdaterPanel />
|
||||
</Expander>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const getUpdateStatus = () => api.get('/updater/status');
|
||||
Reference in New Issue
Block a user