feat(F-078): completed feature
This commit is contained in:
@@ -68,8 +68,11 @@ function Sidebar({
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
|
||||
21
project/apps/admin/src/app/(dashboard)/logs/page.tsx
Normal file
21
project/apps/admin/src/app/(dashboard)/logs/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
import { ServerLogViewer } from '@/components/ServerLogViewer';
|
||||
|
||||
export default function ServerLogsPage() {
|
||||
const backendUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.protocol}//${window.location.hostname}:3000`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs del servidor</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Stream en tiempo real via SSE. Las líneas erróneas se resaltan en rojo.
|
||||
</p>
|
||||
</div>
|
||||
<ServerLogViewer backendUrl={backendUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
project/apps/admin/src/components/ServerLogViewer.tsx
Normal file
233
project/apps/admin/src/components/ServerLogViewer.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface LogEntry {
|
||||
time: string;
|
||||
level: number;
|
||||
levelName: string;
|
||||
msg: string;
|
||||
err?: Record<string, unknown>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const LEVEL_STYLES: Record<number, string> = {
|
||||
60: 'text-red-400', // fatal
|
||||
50: 'text-red-400', // error
|
||||
40: 'text-amber-400', // warn
|
||||
30: 'text-green-400', // info
|
||||
20: 'text-gray-400', // debug
|
||||
10: 'text-gray-500', // trace
|
||||
};
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLine(line: string): LogEntry {
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
const level = obj.level ?? 30;
|
||||
const LEVEL_NAMES: Record<number, string> = { 10: 'TRACE', 20: 'DEBUG', 30: 'INFO', 40: 'WARN', 50: 'ERROR', 60: 'FATAL' };
|
||||
return {
|
||||
time: obj.time ?? '',
|
||||
level,
|
||||
levelName: LEVEL_NAMES[level] ?? 'INFO',
|
||||
msg: obj.msg ?? '',
|
||||
err: obj.err,
|
||||
raw: line,
|
||||
};
|
||||
} catch {
|
||||
return { time: '', level: 30, levelName: 'INFO', msg: line, raw: line };
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerLogViewerProps {
|
||||
/** Backend URL for SSE stream, e.g. 'http://192.168.18.93:3000' */
|
||||
backendUrl?: string;
|
||||
}
|
||||
|
||||
export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: ServerLogViewerProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [status, setStatus] = useState<'connecting' | 'live' | 'reconnecting' | 'error'>('connecting');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [autoScroll]);
|
||||
|
||||
// Track if user scrolled up
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isAtBottomRef.current = distFromBottom < 50;
|
||||
setAutoScroll(isAtBottomRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'live') scrollToBottom();
|
||||
}, [logs, scrollToBottom, status]);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus('connecting');
|
||||
|
||||
// Build the SSE URL with the session cookie forwarded
|
||||
// We use a fetch-based approach: open a GET request and read as text/event-stream
|
||||
let aborted = false;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
// Read cookie from document
|
||||
const cookies = document.cookie;
|
||||
const response = await fetch(`${backendUrl}/admin/logs/stream`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
|
||||
if (!response.ok || aborted) {
|
||||
if (!aborted) setStatus('reconnecting');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) { setStatus('error'); return; }
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
setStatus('live');
|
||||
|
||||
while (true) {
|
||||
if (aborted) break;
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const raw of lines) {
|
||||
if (!raw.trim()) continue;
|
||||
if (raw.startsWith('data: ')) {
|
||||
const json = raw.slice(6);
|
||||
setLogs(prev => {
|
||||
const next = [...prev, parseLine(json)];
|
||||
return next.length > 2000 ? next.slice(-2000) : next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!aborted) setStatus('reconnecting');
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
}, [backendUrl]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
{status === 'live' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-green-400 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
EN VIVO
|
||||
</span>
|
||||
)}
|
||||
{status === 'connecting' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-400 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
CONECTANDO...
|
||||
</span>
|
||||
)}
|
||||
{(status === 'reconnecting' || status === 'error') && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-amber-400 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
{status === 'reconnecting' ? 'RECONECTANDO...' : 'ERROR DE CONEXIÓN'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{logs.length} líneas</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={e => { setAutoScroll(e.target.checked); if (e.target.checked) bottomRef.current?.scrollIntoView(); }}
|
||||
className="rounded border-gray-600 text-[#2D6A4F] focus:ring-[#2D6A4F]"
|
||||
/>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setLogs([])}
|
||||
className="px-2 py-1 text-xs border border-gray-600 text-gray-400 rounded hover:bg-gray-800 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-[#0d1117] font-mono text-xs leading-relaxed"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{logs.map((entry, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={`border-b border-[#161b22] hover:bg-gray-900/50 ${LEVEL_STYLES[entry.level] ?? 'text-gray-300'}`}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<td className="px-3 py-0.5 text-gray-500 whitespace-nowrap select-all">
|
||||
{formatTime(entry.time)}
|
||||
</td>
|
||||
{/* Level badge */}
|
||||
<td className="px-2 py-0.5 w-14 text-center font-bold whitespace-nowrap">
|
||||
{entry.level >= 50 ? (
|
||||
<span className="text-red-400">ERR</span>
|
||||
) : entry.level >= 40 ? (
|
||||
<span className="text-amber-400">WARN</span>
|
||||
) : entry.level >= 30 ? (
|
||||
<span className="text-green-500">INFO</span>
|
||||
) : entry.level >= 20 ? (
|
||||
<span className="text-gray-500">DEBG</span>
|
||||
) : (
|
||||
<span className="text-gray-600">TRCE</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Message */}
|
||||
<td className="px-3 py-0.5 text-gray-300 select-all">
|
||||
{entry.msg}
|
||||
{entry.err && (
|
||||
<span className="ml-2 text-red-400">
|
||||
{String((entry.err as { message?: string }).message ?? entry.err).slice(0, 120)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/users', label: 'Usuarios', icon: '🔐', permission: 'admin-users.read' },
|
||||
{ href: '/tax-rates', label: 'IVA', icon: '📊', permission: 'orders.read' },
|
||||
{ href: '/audit', label: 'Auditoría', icon: '📋', permission: 'audit.read' },
|
||||
{ href: '/logs', label: 'Logs', icon: '🖥️', permission: 'audit.read' },
|
||||
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user