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' },
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
project/frontend/public/fonts/Almagro.ttf
Normal file
BIN
project/frontend/public/fonts/Almagro.ttf
Normal file
Binary file not shown.
BIN
project/frontend/public/fonts/almagro.woff2
Normal file
BIN
project/frontend/public/fonts/almagro.woff2
Normal file
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
98
project/src/infrastructure/logging/log-broadcaster.ts
Normal file
98
project/src/infrastructure/logging/log-broadcaster.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user