feat: filtro de severidad (Alta/Media/Baja) en el log de errores

Se asigna sola, sin etiquetar nada a mano: backend y errores de render de React (rompen toda la
pantalla) = alta; errores JS sueltos = media; promesas rechazadas sin manejar = baja (suelen ser
fallos de red puntuales, no criticos). Nuevo filtro en el panel ademas del de origen ya existente.

Migracion 014_error_logs_severity.sql (ALTER TABLE ADD COLUMN IF NOT EXISTS). Probado en dev: las
tres severidades se asignan correctamente segun el origen, un valor invalido del frontend cae en
media, y el filtro combinado con origen funciona.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-17 14:50:36 -05:00
parent a06df86881
commit feadd2e715
14 changed files with 81 additions and 23 deletions
+6 -4
View File
@@ -136,11 +136,13 @@
### 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
reportar errores propios (incluso sin sesión). `{ message, stack?, url?, severity? }`
(`severity`: `alta`/`media`/`baja`; si falta o es inválida, cae en `media`). 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`).
- `GET /api/error-logs?source=backend|frontend&severity=alta|media|baja&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`) con `severity=alta` siempre — nunca se etiqueta a mano.
- `DELETE /api/error-logs` — solo Superadministrador. Borra todo el historial.
### Dashboard (`/api/dashboard`)
+3 -1
View File
@@ -105,7 +105,9 @@
- 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").
del todo desde el panel ("Limpiar registro"). `severity` (alta/media/baja) nunca se etiqueta a
mano: backend y errores de render de React = alta, errores JS sueltos = media, promesas
rechazadas sin manejar = baja.
## Riesgos o decisiones
+7
View File
@@ -1,5 +1,12 @@
# Changelog - project.linkedcorp.net
## [1.21.1] - 2026-09-17
### Added
- Filtro por severidad (Alta/Media/Baja) en el panel "Errores del sistema", además del filtro por
origen ya existente. La severidad se asigna sola, sin etiquetar nada a mano: errores de backend
y errores de render de React (rompen toda la pantalla) = Alta; errores JS sueltos = Media;
promesas rechazadas sin manejar = Baja (suelen ser fallos de red puntuales, no críticos).
## [1.21.0] - 2026-09-17
### Added
- Log de errores para el administrador, panel nuevo en Configuración ("Errores del sistema",
+1 -1
View File
@@ -1 +1 @@
1.21.0
1.21.1
@@ -0,0 +1,4 @@
-- Severidad automatica (nunca etiquetada a mano): backend y errores de render de React (rompen
-- toda la pantalla) = alta; errores JS sueltos = media; promesas rechazadas sin catch = baja.
-- Filas ya existentes (si las hay) quedan en 'media' por defecto, criterio neutral razonable.
ALTER TABLE error_logs ADD COLUMN IF NOT EXISTS severity ENUM('alta', 'media', 'baja') NOT NULL DEFAULT 'media' AFTER source;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.21.0",
"version": "1.21.1",
"private": true,
"type": "module",
"main": "src/server.js",
+1 -1
View File
@@ -2,7 +2,7 @@ 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 }));
ok(res, await service.listErrors({ source: req.query.source, severity: req.query.severity, limit: req.query.limit }));
});
export const postErrorLog = asyncHandler(async (req, res) => {
+7 -6
View File
@@ -4,11 +4,11 @@ import { pool } from '../../config/db.js';
// 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 }) {
export async function create({ source, severity, 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]
`INSERT INTO error_logs (source, severity, message, stack, url, method, user_id, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[source, severity, 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)`,
@@ -16,13 +16,14 @@ export async function create({ source, message, stack, url, method, userId, user
);
}
export async function findAll({ source, limit = 200 } = {}) {
export async function findAll({ source, severity, limit = 200 } = {}) {
const clauses = [];
const params = [];
if (source) { clauses.push('el.source = ?'); params.push(source); }
if (severity) { clauses.push('el.severity = ?'); params.push(severity); }
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,
`SELECT el.id, el.source, el.severity, 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 ?`,
+12
View File
@@ -3,12 +3,23 @@ import * as repo from './repository.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
// Severidad SIEMPRE automatica, nunca etiquetada a mano (mismo criterio que la criticidad del
// panel de Actualizaciones): backend y errores de render de React "alta" (rompen toda la
// pantalla), errores JS sueltos "media", promesas rechazadas sin catch "baja" (suelen ser fallos
// de red no criticos). El frontend indica cual de los tres le paso; si manda algo invalido u
// omite el dato, cae en "media" como default neutral.
const VALID_SEVERITIES = new Set(['alta', 'media', 'baja']);
function normalizeSeverity(value) {
return VALID_SEVERITIES.has(value) ? value : 'media';
}
// 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',
severity: 'alta',
message: (err?.message || String(err)).slice(0, 2000),
stack: (err?.stack || '').slice(0, 5000) || null,
url: req?.originalUrl?.slice(0, 500) || null,
@@ -39,6 +50,7 @@ export async function logFrontendError(payload, req) {
if (!message) throw new HttpError(400, 'message es obligatorio');
await repo.create({
source: 'frontend',
severity: normalizeSeverity(payload.severity),
message,
stack: (payload.stack || '').slice(0, 5000) || null,
url: (payload.url || '').slice(0, 500) || null,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.21.0",
"version": "1.21.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,6 +19,7 @@ export class ErrorBoundary extends Component {
message: error?.message || 'Error de render',
stack: `${error?.stack || ''}\n${info?.componentStack || ''}`.slice(0, 5000),
url: window.location.href,
severity: 'alta', // rompe toda la pantalla, no solo una parte
});
}
+10
View File
@@ -87,6 +87,11 @@ export const messages = {
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.',
error_logs_source: 'Origen',
error_logs_severity: 'Severidad',
error_logs_severity_alta: 'Alta',
error_logs_severity_media: 'Media',
error_logs_severity_baja: 'Baja',
password_show: 'Mostrar clave',
password_hide: 'Ocultar clave',
common_new: 'Nuevo',
@@ -358,6 +363,11 @@ export const messages = {
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.',
error_logs_source: 'Source',
error_logs_severity: 'Severity',
error_logs_severity_alta: 'High',
error_logs_severity_media: 'Medium',
error_logs_severity_baja: 'Low',
password_show: 'Show password',
password_hide: 'Hide password',
common_new: 'New',
@@ -7,11 +7,14 @@ import { Button } from '../../components/Button.jsx';
import { Badge } from '../../components/Badge.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
const SEVERITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' };
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]);
const [severity, setSeverity] = useState('');
const { data: logs, loading, reload } = useAsync(() => listErrorLogs({ source, severity }), [source, severity]);
function confirmClear() {
openDialog({
@@ -34,7 +37,7 @@ export default function ErrorLogsPanel() {
return (
<div>
<div style={{ display: 'flex', gap: 10, marginBottom: 12, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10, marginBottom: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ width: 160 }}>
<ComboBox
options={[
@@ -42,7 +45,18 @@ export default function ErrorLogsPanel() {
{ value: 'backend', label: 'Backend' },
{ value: 'frontend', label: 'Frontend' },
]}
value={source} onChange={setSource}
value={source} onChange={setSource} placeholder={t('error_logs_source')}
/>
</div>
<div style={{ width: 160 }}>
<ComboBox
options={[
{ value: '', label: t('common_all') },
{ value: 'alta', label: t('error_logs_severity_alta') },
{ value: 'media', label: t('error_logs_severity_media') },
{ value: 'baja', label: t('error_logs_severity_baja') },
]}
value={severity} onChange={setSeverity} placeholder={t('error_logs_severity')}
/>
</div>
<Button variant="standard" icon={Trash2} onClick={confirmClear} disabled={!logs || logs.length === 0}>
@@ -56,7 +70,8 @@ export default function ErrorLogsPanel() {
{(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>
<Badge color={SEVERITY_COLOR[log.severity] || 'muted'}>{t(`error_logs_severity_${log.severity}`)}</Badge>
<Badge color="muted">{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>
+8 -4
View File
@@ -5,26 +5,30 @@ const MAX_REPORTS_PER_SESSION = 20;
let reportedCount = 0;
let installed = false;
function send(message, stack) {
function send(message, stack, severity) {
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,
severity,
});
}
// 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.
// vez al arrancar (main.jsx). Los errores de render de React los captura ErrorBoundary aparte
// (severidad "alta" ahi, rompen toda la pantalla).
export function installGlobalErrorReporter() {
if (installed) return;
installed = true;
// Error JS suelto: la pagina suele seguir funcionando parcialmente.
window.addEventListener('error', (event) => {
send(event.message, event.error?.stack);
send(event.message, event.error?.stack, 'media');
});
// Promesa rechazada sin catch: casi siempre un fallo de red/API puntual, no critico.
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
send(reason?.message || String(reason), reason?.stack);
send(reason?.message || String(reason), reason?.stack, 'baja');
});
}