feat(F-078): completed feature

This commit is contained in:
chattie
2026-08-20 05:55:46 +02:00
parent d799dd07d7
commit fffb52721a
26 changed files with 1296 additions and 39 deletions

View 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>
);
}