feat: log de errores para el admin (backend + frontend)
Panel nuevo en Configuracion ("Errores del sistema", solo superadmin): errores de backend
(excepciones 500 no manejadas, con stack/ruta/metodo/usuario) y de frontend (errores JS y
promesas sin catch en el navegador del usuario, antes invisibles para cualquiera mas alla de esa
persona). Filtro por origen, boton para limpiar el historial.
Error Boundary de React: si algo rompe el render, ya no queda pantalla en blanco -- aviso simple
con boton de recargar, y el error queda reportado igual.
Nuevo error_logs (tabla, se recorta sola a 1000 filas) + GET/DELETE /api/error-logs (superadmin)
+ POST /api/error-logs (publico, rate-limited, para reportar incluso sin sesion).
Probado de punta a punta en dev: error real de backend (500) capturado con stack completo y
usuario correcto, error JS global y promesa rechazada capturados desde el navegador, panel
muestra ambos y el boton de limpiar funciona. Se encontro y corrigio un bug real en el camino: la
identificacion de usuario opcional leia jwt.payload.id en vez de .sub (como realmente lo firma
auth/service.js), asi que nunca identificaba a nadie.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,15 @@
|
|||||||
`backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica
|
`backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica
|
||||||
ningún cambio, la actualización real sigue siendo manual.
|
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`)
|
### 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
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
| `attachments` | archivos adjuntos a proyecto o actividad |
|
| `attachments` | archivos adjuntos a proyecto o actividad |
|
||||||
| `notifications` | notificaciones in-app por usuario |
|
| `notifications` | notificaciones in-app por usuario |
|
||||||
| `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) |
|
| `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
|
## Relaciones clave
|
||||||
|
|
||||||
@@ -102,6 +103,9 @@
|
|||||||
además sea `project_lead` global, comportamiento previo sin cambios).
|
además sea `project_lead` global, comportamiento previo sin cambios).
|
||||||
- Empresa asumida única (no hay tabla `companies` en el MVP).
|
- 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.
|
- 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
|
## Riesgos o decisiones
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.20.0] - 2026-09-17
|
||||||
### Added
|
### Added
|
||||||
- Convertir una actividad en proyecto (menú de tres puntos en el listado de Actividades). Como
|
- Convertir una actividad en proyecto (menú de tres puntos en el listado de Actividades). Como
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.20.0",
|
"version": "1.21.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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';
|
import updaterRoutes from './modules/updater/routes.js';
|
||||||
|
import errorLogsRoutes from './modules/errorLogs/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ 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);
|
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
|
// 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).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HttpError } from '../utils/http.js';
|
import { HttpError } from '../utils/http.js';
|
||||||
|
import { logBackendError } from '../modules/errorLogs/service.js';
|
||||||
|
|
||||||
export function notFoundHandler(req, res) {
|
export function notFoundHandler(req, res) {
|
||||||
res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' });
|
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 });
|
return res.status(err.status).json({ success: false, data: null, error: err.message });
|
||||||
}
|
}
|
||||||
console.error('[error]', err);
|
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';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.20.0",
|
"version": "1.21.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { UiProvider } from '../context/ui-context.jsx';
|
|||||||
import { BrandingProvider } from '../context/branding-context.jsx';
|
import { BrandingProvider } from '../context/branding-context.jsx';
|
||||||
import { ContentDialogHost } from '../components/ContentDialog.jsx';
|
import { ContentDialogHost } from '../components/ContentDialog.jsx';
|
||||||
import { ToastHost } from '../components/ToastHost.jsx';
|
import { ToastHost } from '../components/ToastHost.jsx';
|
||||||
|
import { ErrorBoundary } from '../components/ErrorBoundary.jsx';
|
||||||
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
||||||
import AppShell from './AppShell.jsx';
|
import AppShell from './AppShell.jsx';
|
||||||
import LoginPage from '../pages/Login/LoginPage.jsx';
|
import LoginPage from '../pages/Login/LoginPage.jsx';
|
||||||
@@ -21,6 +22,7 @@ import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
<UiProvider>
|
<UiProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
@@ -47,5 +49,6 @@ export default function App() {
|
|||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</UiProvider>
|
</UiProvider>
|
||||||
</BrandingProvider>
|
</BrandingProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
justifyContent: 'center', gap: 12, padding: 24, textAlign: 'center',
|
||||||
|
background: 'rgb(var(--canvas, 32 32 32))', color: 'rgb(var(--body, 230 230 230))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0 }}>Algo salió mal</h2>
|
||||||
|
<p style={{ margin: 0, opacity: .7, maxWidth: 420 }}>
|
||||||
|
El error ya quedó registrado para el administrador. Probá recargar la página.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
marginTop: 8, padding: '8px 18px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgb(var(--accent, 37 99 235))', color: '#fff', fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Recargar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,11 @@ export const messages = {
|
|||||||
updater_criticality_media: 'Media',
|
updater_criticality_media: 'Media',
|
||||||
updater_criticality_baja: 'Baja',
|
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.',
|
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_show: 'Mostrar clave',
|
||||||
password_hide: 'Ocultar clave',
|
password_hide: 'Ocultar clave',
|
||||||
common_new: 'Nuevo',
|
common_new: 'Nuevo',
|
||||||
@@ -348,6 +353,11 @@ export const messages = {
|
|||||||
updater_criticality_media: 'Medium',
|
updater_criticality_media: 'Medium',
|
||||||
updater_criticality_baja: 'Low',
|
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.',
|
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_show: 'Show password',
|
||||||
password_hide: 'Hide password',
|
password_hide: 'Hide password',
|
||||||
common_new: 'New',
|
common_new: 'New',
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './app/App.jsx';
|
import App from './app/App.jsx';
|
||||||
|
import { installGlobalErrorReporter } from './utils/errorReporter.js';
|
||||||
import './styles/tokens.css';
|
import './styles/tokens.css';
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
|
||||||
|
installGlobalErrorReporter();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
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 { 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';
|
||||||
@@ -9,6 +9,7 @@ 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';
|
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||||
|
import ErrorLogsPanel from './ErrorLogsPanel.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();
|
||||||
@@ -106,6 +107,10 @@ export default function ConfiguracionPage() {
|
|||||||
<Expander icon={RefreshCw} title={t('config_updater')}>
|
<Expander icon={RefreshCw} title={t('config_updater')}>
|
||||||
<UpdaterPanel />
|
<UpdaterPanel />
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
<Expander icon={Bug} title={t('config_error_logs')}>
|
||||||
|
<ErrorLogsPanel />
|
||||||
|
</Expander>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginBottom: 12, alignItems: 'center' }}>
|
||||||
|
<div style={{ width: 160 }}>
|
||||||
|
<ComboBox
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('common_all') },
|
||||||
|
{ value: 'backend', label: 'Backend' },
|
||||||
|
{ value: 'frontend', label: 'Frontend' },
|
||||||
|
]}
|
||||||
|
value={source} onChange={setSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="standard" icon={Trash2} onClick={confirmClear} disabled={!logs || logs.length === 0}>
|
||||||
|
{t('error_logs_clear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="text-muted">{t('common_loading')}</div>}
|
||||||
|
{logs && logs.length === 0 && <div className="text-muted">{t('error_logs_empty')}</div>}
|
||||||
|
|
||||||
|
{(logs || []).map((log) => (
|
||||||
|
<div key={log.id} className="panel-card" style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Badge color={log.source === 'backend' ? 'danger' : 'warning'}>{log.source}</Badge>
|
||||||
|
<span className="text-muted" style={{ fontSize: '.72rem' }}>{String(log.created_at).slice(0, 16)}</span>
|
||||||
|
{log.user_name && <span className="text-muted" style={{ fontSize: '.72rem' }}>· {log.user_name}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '.82rem', marginBottom: 4 }}>{log.message}</div>
|
||||||
|
{log.url && (
|
||||||
|
<div className="text-muted" style={{ fontSize: '.7rem' }}>
|
||||||
|
{log.method ? `${log.method} ` : ''}{log.url}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.stack && (
|
||||||
|
<details style={{ marginTop: 6 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontSize: '.72rem', color: 'rgb(var(--muted))' }}>Stack trace</summary>
|
||||||
|
<pre style={{ fontSize: '.68rem', whiteSpace: 'pre-wrap', marginTop: 4 }}>{log.stack}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user