diff --git a/API.md b/API.md index 8db2ba4..f17844a 100644 --- a/API.md +++ b/API.md @@ -134,6 +134,15 @@ `backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica ningún cambio, la actualización real sigue siendo manual. +### Log de errores (`/api/error-logs`) +- `POST /api/error-logs` — público (sin auth, rate-limited a 30/15min), lo usa el frontend para + reportar errores propios (incluso sin sesión). `{ message, stack?, url? }`. Si llega un token + válido en `Authorization`, se guarda el usuario asociado, sin exigirlo. +- `GET /api/error-logs?source=backend|frontend&limit=` — solo Superadministrador. Lista los + errores más recientes (máx 1000 guardados, se recorta solo). Backend guarda `stack`/`url`/ + `method`/usuario automáticamente en cada excepción 500 no manejada (ver `errorHandler.js`). +- `DELETE /api/error-logs` — solo Superadministrador. Borra todo el historial. + ### 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 diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 345676c..d00d698 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -38,6 +38,7 @@ | `attachments` | archivos adjuntos a proyecto o actividad | | `notifications` | notificaciones in-app por usuario | | `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) | +| `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración | ## Relaciones clave @@ -102,6 +103,9 @@ además sea `project_lead` global, comportamiento previo sin cambios). - Empresa asumida única (no hay tabla `companies` en el MVP). - Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar. +- `error_logs`: sin límite de tiempo, pero se recorta solo a los 1000 registros más recientes en + cada inserción (evita crecimiento sin control si algo entra en loop de errores). Se puede vaciar + del todo desde el panel ("Limpiar registro"). ## Riesgos o decisiones diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ed9fc..ee3164c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog - project.linkedcorp.net +## [1.21.0] - 2026-09-17 +### Added +- Log de errores para el administrador, panel nuevo en Configuración ("Errores del sistema", + solo superadmin): lista errores de backend (excepciones 500 no manejadas, con stack completo, + ruta, método y usuario si estaba logueado) y de frontend (errores JS y promesas sin `catch` en + el navegador del usuario, capturados automáticamente — antes solo aparecían en la consola del + navegador de esa persona, invisibles para cualquier otro). Filtro por origen y botón para + limpiar el historial. +- Error Boundary de React: si algo rompe el render de la app, ya no queda una pantalla en blanco + — se muestra un aviso simple con botón para recargar, y el error queda reportado igual. +- Nuevo `error_logs` (tabla + `GET/DELETE /api/error-logs`, solo superadmin) y + `POST /api/error-logs` (público, con rate-limit, para que el frontend pueda reportar incluso + sin sesión — ej. en la pantalla de login). + ## [1.20.0] - 2026-09-17 ### Added - Convertir una actividad en proyecto (menú de tres puntos en el listado de Actividades). Como diff --git a/VERSION b/VERSION index 3989355..3500250 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.20.0 +1.21.0 diff --git a/backend/database/migrations/013_error_logs.sql b/backend/database/migrations/013_error_logs.sql new file mode 100644 index 0000000..134996b --- /dev/null +++ b/backend/database/migrations/013_error_logs.sql @@ -0,0 +1,15 @@ +-- Log de errores para el admin (panel en Configuracion): backend (excepciones 500 no manejadas) +-- y frontend (errores JS/promesas no manejadas en el navegador, reportados via POST publico). +CREATE TABLE IF NOT EXISTS error_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + source ENUM('backend', 'frontend') NOT NULL, + message TEXT NOT NULL, + stack TEXT NULL, + url VARCHAR(500) NULL, + method VARCHAR(10) NULL, + user_id INT NULL, + user_agent VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_error_logs_created_at (created_at), + CONSTRAINT fk_error_logs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 14af306..6454e17 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.20.0", + "version": "1.21.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index f7f3b17..b7fe516 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -21,6 +21,7 @@ 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'; +import errorLogsRoutes from './modules/errorLogs/routes.js'; export const app = express(); @@ -46,6 +47,7 @@ app.use('/api/company', companyRoutes); app.use('/api/recurring-tasks', recurringTasksRoutes); app.use('/api/search', searchRoutes); app.use('/api/updater', updaterRoutes); +app.use('/api/error-logs', errorLogsRoutes); // 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). diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 04937d5..44f3091 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -1,4 +1,5 @@ import { HttpError } from '../utils/http.js'; +import { logBackendError } from '../modules/errorLogs/service.js'; export function notFoundHandler(req, res) { res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' }); @@ -9,6 +10,9 @@ export function errorHandler(err, req, res, next) { // eslint-disable-line no-un return res.status(err.status).json({ success: false, data: null, error: err.message }); } console.error('[error]', err); + // Fire-and-forget: logBackendError nunca tira (se traga sus propios fallos), no bloquea la + // respuesta al cliente. + logBackendError(err, req); const isProd = process.env.NODE_ENV === 'production'; res.status(500).json({ success: false, diff --git a/backend/src/modules/errorLogs/controller.js b/backend/src/modules/errorLogs/controller.js new file mode 100644 index 0000000..b9b3257 --- /dev/null +++ b/backend/src/modules/errorLogs/controller.js @@ -0,0 +1,16 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getErrorLogs = asyncHandler(async (req, res) => { + ok(res, await service.listErrors({ source: req.query.source, limit: req.query.limit })); +}); + +export const postErrorLog = asyncHandler(async (req, res) => { + await service.logFrontendError(req.body, req); + ok(res, { logged: true }, 201); +}); + +export const deleteErrorLogs = asyncHandler(async (req, res) => { + await service.clearErrors(); + ok(res, { cleared: true }); +}); diff --git a/backend/src/modules/errorLogs/repository.js b/backend/src/modules/errorLogs/repository.js new file mode 100644 index 0000000..03fc2af --- /dev/null +++ b/backend/src/modules/errorLogs/repository.js @@ -0,0 +1,36 @@ +import { pool } from '../../config/db.js'; + +// Evita crecimiento sin limite (el frontend puede reportar seguido si algo entra en loop de +// errores): al insertar, se recorta a los MAX_ROWS mas recientes. +const MAX_ROWS = 1000; + +export async function create({ source, message, stack, url, method, userId, userAgent }) { + await pool.query( + `INSERT INTO error_logs (source, message, stack, url, method, user_id, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [source, message, stack || null, url || null, method || null, userId || null, userAgent || null] + ); + await pool.query( + `DELETE FROM error_logs WHERE id NOT IN (SELECT id FROM (SELECT id FROM error_logs ORDER BY created_at DESC LIMIT ?) x)`, + [MAX_ROWS] + ); +} + +export async function findAll({ source, limit = 200 } = {}) { + const clauses = []; + const params = []; + if (source) { clauses.push('el.source = ?'); params.push(source); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [rows] = await pool.query( + `SELECT el.id, el.source, el.message, el.stack, el.url, el.method, + el.user_id, u.name AS user_name, el.user_agent, el.created_at + FROM error_logs el LEFT JOIN users u ON u.id = el.user_id + ${where} ORDER BY el.created_at DESC LIMIT ?`, + [...params, Number(limit)] + ); + return rows; +} + +export async function clearAll() { + await pool.query('DELETE FROM error_logs'); +} diff --git a/backend/src/modules/errorLogs/routes.js b/backend/src/modules/errorLogs/routes.js new file mode 100644 index 0000000..30041b7 --- /dev/null +++ b/backend/src/modules/errorLogs/routes.js @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js'; +import { getErrorLogs, postErrorLog, deleteErrorLogs } from './controller.js'; + +const router = Router(); + +// Publico (sin requireAuth): el frontend debe poder reportar un error incluso en paginas sin +// sesion (login, registro). Rate-limited para que no se pueda usar para floodear la tabla. +const reportLimiter = rateLimit({ windowMs: 5 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false }); +router.post('/', reportLimiter, postErrorLog); + +router.get('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), getErrorLogs); +router.delete('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), deleteErrorLogs); + +export default router; diff --git a/backend/src/modules/errorLogs/service.js b/backend/src/modules/errorLogs/service.js new file mode 100644 index 0000000..f2b7020 --- /dev/null +++ b/backend/src/modules/errorLogs/service.js @@ -0,0 +1,52 @@ +import jwt from 'jsonwebtoken'; +import * as repo from './repository.js'; +import { env } from '../../config/env.js'; +import { HttpError } from '../../utils/http.js'; + +// Nunca debe tirar: si falla el logueo de un error, no puede convertirse en OTRO error sin +// manejar que rompa la respuesta original. Se traga cualquier fallo propio (solo avisa por consola). +export async function logBackendError(err, req) { + try { + await repo.create({ + source: 'backend', + message: (err?.message || String(err)).slice(0, 2000), + stack: (err?.stack || '').slice(0, 5000) || null, + url: req?.originalUrl?.slice(0, 500) || null, + method: req?.method || null, + userId: req?.currentUser?.id || null, + userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null, + }); + } catch (logErr) { + console.error('[error_logs] no se pudo guardar el error:', logErr.message); + } +} + +// El reporte desde el navegador es publico (debe funcionar incluso en /login, sin sesion) — si +// hay un token valido lo usamos solo para saber quien era, sin exigirlo. +function decodeOptionalUserId(req) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return null; + try { + return jwt.verify(token, env.jwtSecret).sub || null; + } catch { + return null; + } +} + +export async function logFrontendError(payload, req) { + const message = (payload.message || '').trim().slice(0, 2000); + if (!message) throw new HttpError(400, 'message es obligatorio'); + await repo.create({ + source: 'frontend', + message, + stack: (payload.stack || '').slice(0, 5000) || null, + url: (payload.url || '').slice(0, 500) || null, + method: null, + userId: decodeOptionalUserId(req), + userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null, + }); +} + +export const listErrors = (filters) => repo.findAll(filters); +export const clearErrors = () => repo.clearAll(); diff --git a/frontend/package.json b/frontend/package.json index 32cc533..ef5d399 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.20.0", + "version": "1.21.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 8ddbaed..1806a9a 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -4,6 +4,7 @@ import { UiProvider } from '../context/ui-context.jsx'; import { BrandingProvider } from '../context/branding-context.jsx'; import { ContentDialogHost } from '../components/ContentDialog.jsx'; import { ToastHost } from '../components/ToastHost.jsx'; +import { ErrorBoundary } from '../components/ErrorBoundary.jsx'; import { ProtectedRoute } from './ProtectedRoute.jsx'; import AppShell from './AppShell.jsx'; import LoginPage from '../pages/Login/LoginPage.jsx'; @@ -21,31 +22,33 @@ import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; export default function App() { return ( - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + + ); } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..7e98741 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,52 @@ +import { Component } from 'react'; +import { reportError } from '../services/errorLogs.js'; + +// Ultimo recurso ante un error de render de React (que si no, deja la pantalla en blanco sin +// ninguna pista). No usa hooks a proposito (useUi podria fallar si el error viene de un provider +// roto) — texto fijo, estilos inline con los tokens de color de la app. +export class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error, info) { + reportError({ + message: error?.message || 'Error de render', + stack: `${error?.stack || ''}\n${info?.componentStack || ''}`.slice(0, 5000), + url: window.location.href, + }); + } + + render() { + if (this.state.hasError) { + return ( +
+

Algo salió mal

+

+ El error ya quedó registrado para el administrador. Probá recargar la página. +

+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 084502e..79f6b59 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -82,6 +82,11 @@ export const messages = { 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.', + + config_error_logs: 'Errores del sistema', + error_logs_clear: 'Limpiar registro', + error_logs_clear_confirm_msg: '¿Seguro que deseas borrar todo el historial de errores registrados? No se puede deshacer.', + error_logs_empty: 'No hay errores registrados.', password_show: 'Mostrar clave', password_hide: 'Ocultar clave', common_new: 'Nuevo', @@ -348,6 +353,11 @@ export const messages = { 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.', + + config_error_logs: 'System errors', + error_logs_clear: 'Clear log', + error_logs_clear_confirm_msg: 'Are you sure you want to delete the entire error log history? This cannot be undone.', + error_logs_empty: 'No errors logged.', password_show: 'Show password', password_hide: 'Hide password', common_new: 'New', diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 721d3eb..a7ba8e2 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,9 +1,12 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './app/App.jsx'; +import { installGlobalErrorReporter } from './utils/errorReporter.js'; import './styles/tokens.css'; import './styles/app.css'; +installGlobalErrorReporter(); + ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx index b966f59..87dbe5d 100644 --- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -1,5 +1,5 @@ import { Navigate } from 'react-router-dom'; -import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw } from 'lucide-react'; +import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { Expander } from '../../components/Expander.jsx'; @@ -9,6 +9,7 @@ import { CatalogEditor } from './CatalogEditor.jsx'; import SmtpSettingsPanel from './SmtpSettingsPanel.jsx'; import CompanySettingsPanel from './CompanySettingsPanel.jsx'; import UpdaterPanel from './UpdaterPanel.jsx'; +import ErrorLogsPanel from './ErrorLogsPanel.jsx'; export default function ConfiguracionPage() { const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi(); @@ -106,6 +107,10 @@ export default function ConfiguracionPage() { + + + + ); } diff --git a/frontend/src/pages/Configuracion/ErrorLogsPanel.jsx b/frontend/src/pages/Configuracion/ErrorLogsPanel.jsx new file mode 100644 index 0000000..fc05cce --- /dev/null +++ b/frontend/src/pages/Configuracion/ErrorLogsPanel.jsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { Trash2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listErrorLogs, clearErrorLogs } from '../../services/errorLogs.js'; +import { Button } from '../../components/Button.jsx'; +import { Badge } from '../../components/Badge.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; + +export default function ErrorLogsPanel() { + const { t, pushToast, openDialog } = useUi(); + const runAction = useAction(); + const [source, setSource] = useState(''); + const { data: logs, loading, reload } = useAsync(() => listErrorLogs({ source }), [source]); + + function confirmClear() { + openDialog({ + tone: 'warning', + title: t('error_logs_clear'), + message: t('error_logs_clear_confirm_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { + await clearErrorLogs(); + await reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + + return ( +
+
+
+ +
+ +
+ + {loading &&
{t('common_loading')}
} + {logs && logs.length === 0 &&
{t('error_logs_empty')}
} + + {(logs || []).map((log) => ( +
+
+ {log.source} + {String(log.created_at).slice(0, 16)} + {log.user_name && · {log.user_name}} +
+
{log.message}
+ {log.url && ( +
+ {log.method ? `${log.method} ` : ''}{log.url} +
+ )} + {log.stack && ( +
+ Stack trace +
{log.stack}
+
+ )} +
+ ))} +
+ ); +} diff --git a/frontend/src/services/errorLogs.js b/frontend/src/services/errorLogs.js new file mode 100644 index 0000000..c21c9c5 --- /dev/null +++ b/frontend/src/services/errorLogs.js @@ -0,0 +1,14 @@ +import { api } from './api.js'; + +// Nunca debe tirar hacia arriba: si el reporte de error falla (red caida, backend abajo), no +// puede convertirse el reportador en otro error sin manejar. +export function reportError(payload) { + return api.post('/error-logs', payload).catch(() => {}); +} + +export function listErrorLogs(params = {}) { + const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString(); + return api.get(`/error-logs${qs ? `?${qs}` : ''}`); +} + +export const clearErrorLogs = () => api.delete('/error-logs'); diff --git a/frontend/src/utils/errorReporter.js b/frontend/src/utils/errorReporter.js new file mode 100644 index 0000000..9783af9 --- /dev/null +++ b/frontend/src/utils/errorReporter.js @@ -0,0 +1,30 @@ +import { reportError } from '../services/errorLogs.js'; + +// Tope por sesion: evita floodear el backend si algo entra en un loop de errores repetido. +const MAX_REPORTS_PER_SESSION = 20; +let reportedCount = 0; +let installed = false; + +function send(message, stack) { + if (reportedCount >= MAX_REPORTS_PER_SESSION) return; + reportedCount += 1; + reportError({ + message: String(message ?? 'Error desconocido').slice(0, 2000), + stack: stack ? String(stack).slice(0, 5000) : undefined, + url: window.location.href, + }); +} + +// Captura errores JS no manejados y promesas rechazadas sin catch en toda la app — llamar UNA +// vez al arrancar (main.jsx). Los errores de render de React los captura ErrorBoundary aparte. +export function installGlobalErrorReporter() { + if (installed) return; + installed = true; + window.addEventListener('error', (event) => { + send(event.message, event.error?.stack); + }); + window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + send(reason?.message || String(reason), reason?.stack); + }); +}