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:
@@ -136,11 +136,13 @@
|
|||||||
|
|
||||||
### Log de errores (`/api/error-logs`)
|
### Log de errores (`/api/error-logs`)
|
||||||
- `POST /api/error-logs` — público (sin auth, rate-limited a 30/15min), lo usa el frontend para
|
- `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.
|
válido en `Authorization`, se guarda el usuario asociado, sin exigirlo.
|
||||||
- `GET /api/error-logs?source=backend|frontend&limit=` — solo Superadministrador. Lista los
|
- `GET /api/error-logs?source=backend|frontend&severity=alta|media|baja&limit=` — solo
|
||||||
errores más recientes (máx 1000 guardados, se recorta solo). Backend guarda `stack`/`url`/
|
Superadministrador. Lista los errores más recientes (máx 1000 guardados, se recorta solo).
|
||||||
`method`/usuario automáticamente en cada excepción 500 no manejada (ver `errorHandler.js`).
|
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.
|
- `DELETE /api/error-logs` — solo Superadministrador. Borra todo el historial.
|
||||||
|
|
||||||
### Dashboard (`/api/dashboard`)
|
### Dashboard (`/api/dashboard`)
|
||||||
|
|||||||
+3
-1
@@ -105,7 +105,9 @@
|
|||||||
- 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
|
- `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
|
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
|
## Riesgos o decisiones
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.21.0] - 2026-09-17
|
||||||
### Added
|
### Added
|
||||||
- Log de errores para el administrador, panel nuevo en Configuración ("Errores del sistema",
|
- Log de errores para el administrador, panel nuevo en Configuración ("Errores del sistema",
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.21.0",
|
"version": "1.21.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ok, asyncHandler } from '../../utils/http.js';
|
|||||||
import * as service from './service.js';
|
import * as service from './service.js';
|
||||||
|
|
||||||
export const getErrorLogs = asyncHandler(async (req, res) => {
|
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) => {
|
export const postErrorLog = asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { pool } from '../../config/db.js';
|
|||||||
// errores): al insertar, se recorta a los MAX_ROWS mas recientes.
|
// errores): al insertar, se recorta a los MAX_ROWS mas recientes.
|
||||||
const MAX_ROWS = 1000;
|
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(
|
await pool.query(
|
||||||
`INSERT INTO error_logs (source, message, stack, url, method, user_id, user_agent)
|
`INSERT INTO error_logs (source, severity, message, stack, url, method, user_id, user_agent)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[source, message, stack || null, url || null, method || null, userId || null, userAgent || null]
|
[source, severity, message, stack || null, url || null, method || null, userId || null, userAgent || null]
|
||||||
);
|
);
|
||||||
await pool.query(
|
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)`,
|
`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 clauses = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (source) { clauses.push('el.source = ?'); params.push(source); }
|
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 where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
const [rows] = await pool.query(
|
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
|
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
|
FROM error_logs el LEFT JOIN users u ON u.id = el.user_id
|
||||||
${where} ORDER BY el.created_at DESC LIMIT ?`,
|
${where} ORDER BY el.created_at DESC LIMIT ?`,
|
||||||
|
|||||||
@@ -3,12 +3,23 @@ import * as repo from './repository.js';
|
|||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { HttpError } from '../../utils/http.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
|
// 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).
|
// manejar que rompa la respuesta original. Se traga cualquier fallo propio (solo avisa por consola).
|
||||||
export async function logBackendError(err, req) {
|
export async function logBackendError(err, req) {
|
||||||
try {
|
try {
|
||||||
await repo.create({
|
await repo.create({
|
||||||
source: 'backend',
|
source: 'backend',
|
||||||
|
severity: 'alta',
|
||||||
message: (err?.message || String(err)).slice(0, 2000),
|
message: (err?.message || String(err)).slice(0, 2000),
|
||||||
stack: (err?.stack || '').slice(0, 5000) || null,
|
stack: (err?.stack || '').slice(0, 5000) || null,
|
||||||
url: req?.originalUrl?.slice(0, 500) || 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');
|
if (!message) throw new HttpError(400, 'message es obligatorio');
|
||||||
await repo.create({
|
await repo.create({
|
||||||
source: 'frontend',
|
source: 'frontend',
|
||||||
|
severity: normalizeSeverity(payload.severity),
|
||||||
message,
|
message,
|
||||||
stack: (payload.stack || '').slice(0, 5000) || null,
|
stack: (payload.stack || '').slice(0, 5000) || null,
|
||||||
url: (payload.url || '').slice(0, 500) || null,
|
url: (payload.url || '').slice(0, 500) || null,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.21.0",
|
"version": "1.21.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class ErrorBoundary extends Component {
|
|||||||
message: error?.message || 'Error de render',
|
message: error?.message || 'Error de render',
|
||||||
stack: `${error?.stack || ''}\n${info?.componentStack || ''}`.slice(0, 5000),
|
stack: `${error?.stack || ''}\n${info?.componentStack || ''}`.slice(0, 5000),
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
|
severity: 'alta', // rompe toda la pantalla, no solo una parte
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export const messages = {
|
|||||||
error_logs_clear: 'Limpiar registro',
|
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_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_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_show: 'Mostrar clave',
|
||||||
password_hide: 'Ocultar clave',
|
password_hide: 'Ocultar clave',
|
||||||
common_new: 'Nuevo',
|
common_new: 'Nuevo',
|
||||||
@@ -358,6 +363,11 @@ export const messages = {
|
|||||||
error_logs_clear: 'Clear log',
|
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_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_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_show: 'Show password',
|
||||||
password_hide: 'Hide password',
|
password_hide: 'Hide password',
|
||||||
common_new: 'New',
|
common_new: 'New',
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import { Button } from '../../components/Button.jsx';
|
|||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
|
||||||
|
const SEVERITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' };
|
||||||
|
|
||||||
export default function ErrorLogsPanel() {
|
export default function ErrorLogsPanel() {
|
||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const [source, setSource] = useState('');
|
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() {
|
function confirmClear() {
|
||||||
openDialog({
|
openDialog({
|
||||||
@@ -34,7 +37,7 @@ export default function ErrorLogsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ width: 160 }}>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
options={[
|
options={[
|
||||||
@@ -42,7 +45,18 @@ export default function ErrorLogsPanel() {
|
|||||||
{ value: 'backend', label: 'Backend' },
|
{ value: 'backend', label: 'Backend' },
|
||||||
{ value: 'frontend', label: 'Frontend' },
|
{ 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>
|
</div>
|
||||||
<Button variant="standard" icon={Trash2} onClick={confirmClear} disabled={!logs || logs.length === 0}>
|
<Button variant="standard" icon={Trash2} onClick={confirmClear} disabled={!logs || logs.length === 0}>
|
||||||
@@ -56,7 +70,8 @@ export default function ErrorLogsPanel() {
|
|||||||
{(logs || []).map((log) => (
|
{(logs || []).map((log) => (
|
||||||
<div key={log.id} className="panel-card" style={{ marginBottom: 8 }}>
|
<div key={log.id} className="panel-card" style={{ marginBottom: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
<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>
|
<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>}
|
{log.user_name && <span className="text-muted" style={{ fontSize: '.72rem' }}>· {log.user_name}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,26 +5,30 @@ const MAX_REPORTS_PER_SESSION = 20;
|
|||||||
let reportedCount = 0;
|
let reportedCount = 0;
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
function send(message, stack) {
|
function send(message, stack, severity) {
|
||||||
if (reportedCount >= MAX_REPORTS_PER_SESSION) return;
|
if (reportedCount >= MAX_REPORTS_PER_SESSION) return;
|
||||||
reportedCount += 1;
|
reportedCount += 1;
|
||||||
reportError({
|
reportError({
|
||||||
message: String(message ?? 'Error desconocido').slice(0, 2000),
|
message: String(message ?? 'Error desconocido').slice(0, 2000),
|
||||||
stack: stack ? String(stack).slice(0, 5000) : undefined,
|
stack: stack ? String(stack).slice(0, 5000) : undefined,
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
|
severity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Captura errores JS no manejados y promesas rechazadas sin catch en toda la app — llamar UNA
|
// 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() {
|
export function installGlobalErrorReporter() {
|
||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
|
// Error JS suelto: la pagina suele seguir funcionando parcialmente.
|
||||||
window.addEventListener('error', (event) => {
|
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) => {
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
const reason = event.reason;
|
const reason = event.reason;
|
||||||
send(reason?.message || String(reason), reason?.stack);
|
send(reason?.message || String(reason), reason?.stack, 'baja');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user