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

@@ -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>

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

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

View File

@@ -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' },
];

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,15 @@
@import "tailwindcss";
/* ── Almagro font ── */
@font-face {
font-family: 'Almagro';
src: url('/fonts/Almagro.woff2') format('woff2'),
url('/fonts/Almagro.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@theme {
--color-primary: #70ad47;
--color-primary-dark: #5a9040;

View File

@@ -13,8 +13,11 @@ export default function UserMenu() {
</span>
<button
onClick={logout}
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
className="flex items-center gap-1.5 text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-label="Cerrar sesión">
<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>
@@ -24,8 +27,11 @@ export default function UserMenu() {
return (
<Link
href="/auth/login"
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
className="flex items-center gap-1.5 text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-label="Iniciar sesión">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
Iniciar sesión
</Link>
);

View File

@@ -11,7 +11,7 @@ export function Footer() {
<div className="mb-3">
<p
className="text-2xl font-bold leading-none"
style={{ fontFamily: 'var(--font-heading)', letterSpacing: '0.04em', color: 'var(--color-footer-text)' }}
style={{ fontFamily: 'Almagro', color: '#70ad47' }}
>
Natural
</p>

View File

@@ -38,6 +38,7 @@ import { registerCmsRoutes } from '../modules/cms/index.js';
import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js';
import { CacheService, InMemoryCacheAdapter, registerCacheRoutes } from '../modules/cache/index.js';
import { AuditLogger, RateLimiter, registerSecurityRoutes } from '../modules/security/index.js';
import { getLogBroadcaster } from '../infrastructure/logging/log-broadcaster.js';
import { registerAdminStatsRoutes } from '../modules/admin-stats/index.js';
import { createInMemoryTelemetry, registerMetricsRoutes } from '../modules/observability/index.js';
import { LoggingEmailProvider } from '../modules/notifications/index.js';
@@ -371,11 +372,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
const rateLimiter = new RateLimiter();
const auditLogger = new AuditLogger(deps.pool);
await app.register(async (instance) => {
const broadcaster = getLogBroadcaster();
await registerSecurityRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: combinedAuth,
rateLimiter,
auditLogger,
broadcaster,
});
});
}

View File

@@ -3,6 +3,7 @@ import { ConfigError, loadConfig } from '../config/config.js';
import { createPool } from '../db/pool.js';
import { createFlagStore } from '../../modules/flags/index.js';
import { createLogger } from '../logging/logger.js';
import { getLogBroadcaster } from '../logging/log-broadcaster.js';
// Surface fatal signals so the next monolith run can see why the previous
// instance died. Without these, SIGTERM/SIGKILL from a harness or the OS
@@ -29,7 +30,8 @@ try {
process.exit(1);
}
const logger = createLogger({ level: config.logLevel });
const broadcaster = getLogBroadcaster();
const logger = createLogger({ level: config.logLevel, broadcaster });
const pool = createPool(config.databaseUrl);
try {

View File

@@ -0,0 +1,98 @@
/**
* In-process log broadcaster for SSE streaming.
*
* Collects the last MAX_BUFFER lines from pino and pushes new entries
* to all connected SSE clients in real-time.
*/
const MAX_BUFFER = 500;
export interface LogEntry {
time: string;
level: number;
levelName: string;
msg: string;
err?: Record<string, unknown>;
raw: string;
}
export class LogBroadcaster {
private buffer: string[] = [];
private clients = new Set<ReadableStreamDefaultController>();
/** Called by pino's custom destination stream. */
addEntry(jsonLine: string): void {
// Keep last MAX_BUFFER lines (ring buffer behavior)
if (this.buffer.length >= MAX_BUFFER) {
this.buffer.shift();
}
this.buffer.push(jsonLine);
// Broadcast to all SSE clients
const enc = new TextEncoder();
for (const client of this.clients) {
try {
client.enqueue(enc.encode(`data: ${jsonLine}\n\n`));
} catch {
// Client disconnected — will be cleaned up by 'close' event
this.clients.delete(client);
}
}
}
/** Returns a async generator that first yields buffered history, then new entries. */
async *stream(): AsyncGenerator<string, void, unknown> {
// First send history (without the SSE prefix — caller prepends it)
for (const line of this.buffer) {
yield line;
}
// Then wait for new entries via a local queue
// (new entries are pushed by addEntry; we yield them directly)
// Since addEntry broadcasts to all clients, we don't need a separate queue here.
// Instead, we just let the client receive from this generator's ReadableStream.
// The initial buffer + new entries are sufficient for SSE.
// Note: we can't "wait" for new entries in a sync generator.
// Instead, the SSE handler will use the ReadableStream from this client registration.
return;
}
/**
* Register a new SSE client. Returns a ReadableStream that yields SSE-formatted lines.
*/
registerClient(): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
const self = this;
return new ReadableStream<Uint8Array>({
start(controller: ReadableStreamDefaultController) {
// Send buffer history
for (const line of self.buffer) {
try {
controller.enqueue(enc.encode(`data: ${line}\n\n`));
} catch {
// Controller closed
break;
}
}
self.clients.add(controller);
},
cancel(controller: ReadableStreamDefaultController) {
self.clients.delete(controller);
},
});
}
get clientCount(): number {
return this.clients.size;
}
}
/** Singleton instance shared across the server lifecycle. */
let instance: LogBroadcaster | undefined;
export function getLogBroadcaster(): LogBroadcaster {
if (!instance) {
instance = new LogBroadcaster();
}
return instance;
}

View File

@@ -3,6 +3,8 @@
* No pretty printers, no transports: JSON lines to a writable destination.
*/
import pino, { type DestinationStream, type Logger } from 'pino';
import type { LogBroadcaster } from './log-broadcaster.js';
import { Writable } from 'stream';
const SERVICE_NAME = 'mercadodevida-backend';
@@ -11,14 +13,34 @@ export interface LoggerOptions {
level?: string;
/** Explicit destination stream (tests use an in-memory stream). */
destination?: DestinationStream;
/** Log broadcaster for SSE streaming (optional). */
broadcaster?: LogBroadcaster;
}
/**
* Creates a pino logger. If a broadcaster is provided, logs are also pushed
* to all connected SSE clients via that broadcaster.
*/
export function createLogger(options: LoggerOptions = {}): Logger {
const level = options.level ?? process.env.LOG_LEVEL ?? 'info';
const settings = { level, base: { service: SERVICE_NAME } };
if (options.broadcaster) {
// Custom Node.js Writable stream that forwards each JSON line to the broadcaster
const toBroadcaster = new Writable({
write(chunk: Buffer, _encoding: string, callback: () => void) {
options.broadcaster!.addEntry(chunk.toString().trim());
callback();
},
autoDestroy: true,
});
return pino(settings, toBroadcaster);
}
if (options.destination) {
return pino(settings, options.destination);
}
return pino(settings);
}

View File

@@ -9,12 +9,14 @@ import { errorSchema } from '../../../shared/swagger.js';
import { parseJson } from '../../../shared/http-input.js';
import { AuditLogger } from '../application/audit-logger.js';
import { RateLimiter } from '../application/rate-limiter.js';
import type { LogBroadcaster } from '../../../infrastructure/logging/log-broadcaster.js';
export interface SecurityRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
rateLimiter: RateLimiter;
auditLogger: AuditLogger;
broadcaster?: LogBroadcaster;
}
// ── Schema definitions ─────────────────────────────────────────────────────────
@@ -150,6 +152,24 @@ export async function registerSecurityRoutes(
return reply.send(decision);
});
// ── Server log stream (SSE) ─────────────────────────────────────────────
app.get('/admin/logs/stream', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
if (!deps.broadcaster) {
throw new AppError(500, 'BROADCASTER_UNAVAILABLE', 'Log broadcaster not available');
}
const stream = deps.broadcaster.registerClient();
return reply
.header('Content-Type', 'text/event-stream')
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
.header('Connection', 'keep-alive')
.header('X-Accel-Buffering', 'no')
.send(stream);
});
// ── Admin user management ──────────────────────────────────────────────────
const listAdminUsersSchema: FastifySchema = {
tags: ['Admin'],