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:
Milton Gonzalez
2026-09-17 14:31:40 -05:00
parent 956e78cd76
commit dec8891506
21 changed files with 394 additions and 30 deletions
@@ -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() {
<Expander icon={RefreshCw} title={t('config_updater')}>
<UpdaterPanel />
</Expander>
<Expander icon={Bug} title={t('config_error_logs')}>
<ErrorLogsPanel />
</Expander>
</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>
);
}