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:
+29
-26
@@ -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 (
|
||||
<BrandingProvider>
|
||||
<UiProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ContentDialogHost />
|
||||
<ToastHost />
|
||||
</AuthProvider>
|
||||
</UiProvider>
|
||||
</BrandingProvider>
|
||||
<ErrorBoundary>
|
||||
<BrandingProvider>
|
||||
<UiProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ContentDialogHost />
|
||||
<ToastHost />
|
||||
</AuthProvider>
|
||||
</UiProvider>
|
||||
</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_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',
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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