import { useEffect, useRef, useState, useCallback } from 'react'; import { connectLiveLogs, connectSecurityLogs, LiveLogEntry, LiveLogFilter, SecurityLogEntry, SecurityLogFilter, } from '../api/logs'; import { Button } from './ui/Button'; import { Pause, Play, Trash2, Filter, Shield, Globe } from 'lucide-react'; /** * Log viewing mode: application logs vs security access logs */ export type LogMode = 'application' | 'security'; interface LiveLogViewerProps { /** Filters for application log mode */ filters?: LiveLogFilter; /** Filters for security log mode */ securityFilters?: SecurityLogFilter; /** Initial log viewing mode */ mode?: LogMode; /** Maximum number of log entries to retain */ maxLogs?: number; /** Additional CSS classes */ className?: string; } /** * Unified display entry for both application and security logs */ interface DisplayLogEntry { timestamp: string; level: string; source: string; message: string; blocked?: boolean; blockReason?: string; clientIP?: string; method?: string; host?: string; uri?: string; status?: number; duration?: number; userAgent?: string; details?: Record; } /** * Convert a LiveLogEntry to unified display format */ const toDisplayFromLive = (entry: LiveLogEntry): DisplayLogEntry => ({ timestamp: entry.timestamp, level: entry.level, source: entry.source || 'app', message: entry.message, details: entry.data, }); /** * Convert a SecurityLogEntry to unified display format */ const toDisplayFromSecurity = (entry: SecurityLogEntry): DisplayLogEntry => ({ timestamp: entry.timestamp, level: entry.level, source: entry.source, message: entry.blocked ? `🚫 BLOCKED: ${entry.block_reason || 'Access denied'}` : `${entry.method} ${entry.uri} → ${entry.status}`, blocked: entry.blocked, blockReason: entry.block_reason, clientIP: entry.client_ip, method: entry.method, host: entry.host, uri: entry.uri, status: entry.status, duration: entry.duration, userAgent: entry.user_agent, details: entry.details, }); /** * Get background/text styling based on log entry properties */ const getEntryStyle = (log: DisplayLogEntry): string => { if (log.blocked) { return 'bg-red-900/30 border-l-2 border-red-500'; } const level = log.level.toLowerCase(); if (level.includes('error') || level.includes('fatal')) return 'text-red-400'; if (level.includes('warn')) return 'text-yellow-400'; return ''; }; /** * Get badge color for security source */ const getSourceBadgeColor = (source: string): string => { const colors: Record = { waf: 'bg-orange-600', crowdsec: 'bg-purple-600', ratelimit: 'bg-blue-600', acl: 'bg-green-600', normal: 'bg-gray-600', cerberus: 'bg-indigo-600', app: 'bg-gray-500', }; return colors[source.toLowerCase()] || 'bg-gray-500'; }; /** * Format timestamp for display */ const formatTimestamp = (timestamp: string): string => { try { const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch { return timestamp; } }; /** * Get level color for application logs */ const getLevelColor = (level: string): string => { const normalized = level.toLowerCase(); if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400'; if (normalized.includes('warn')) return 'text-yellow-400'; if (normalized.includes('info')) return 'text-blue-400'; if (normalized.includes('debug')) return 'text-gray-400'; return 'text-gray-300'; }; // Stable default filter objects to prevent useEffect re-triggers on parent re-render const EMPTY_LIVE_FILTER: LiveLogFilter = {}; const EMPTY_SECURITY_FILTER: SecurityLogFilter = {}; export function LiveLogViewer({ filters = EMPTY_LIVE_FILTER, securityFilters = EMPTY_SECURITY_FILTER, mode = 'security', maxLogs = 500, className = '', }: LiveLogViewerProps) { const [logs, setLogs] = useState([]); const [isPaused, setIsPaused] = useState(false); const [isConnected, setIsConnected] = useState(false); const [connectionError, setConnectionError] = useState(null); const [currentMode, setCurrentMode] = useState(mode); const [textFilter, setTextFilter] = useState(''); const [levelFilter, setLevelFilter] = useState(''); const [sourceFilter, setSourceFilter] = useState(''); const [showBlockedOnly, setShowBlockedOnly] = useState(false); const logContainerRef = useRef(null); const closeConnectionRef = useRef<(() => void) | null>(null); const shouldAutoScroll = useRef(true); const isPausedRef = useRef(isPaused); // Keep ref in sync with state for use in WebSocket handlers useEffect(() => { isPausedRef.current = isPaused; }, [isPaused]); // Handle mode change - clear logs and update filters const handleModeChange = useCallback((newMode: LogMode) => { setCurrentMode(newMode); setLogs([]); setTextFilter(''); setLevelFilter(''); setSourceFilter(''); setShowBlockedOnly(false); }, []); // Connection effect - reconnects when mode or external filters change useEffect(() => { // Close existing connection if (closeConnectionRef.current) { closeConnectionRef.current(); closeConnectionRef.current = null; } const handleOpen = () => { console.log(`${currentMode} log viewer connected`); setIsConnected(true); setConnectionError(null); }; const handleError = (error: Event) => { console.error(`${currentMode} log viewer error:`, error); setIsConnected(false); setConnectionError('Failed to connect to log stream. Check your authentication or try refreshing.'); }; const handleClose = () => { console.log(`${currentMode} log viewer disconnected`); setIsConnected(false); }; if (currentMode === 'security') { // Connect to security logs endpoint const handleSecurityMessage = (entry: SecurityLogEntry) => { // Use ref to check paused state - avoids WebSocket reconnection when pausing if (isPausedRef.current) return; const displayEntry = toDisplayFromSecurity(entry); setLogs((prev) => { const updated = [...prev, displayEntry]; return updated.length > maxLogs ? updated.slice(-maxLogs) : updated; }); }; // Build filters including blocked_only if selected const effectiveFilters: SecurityLogFilter = { ...securityFilters, blocked_only: showBlockedOnly || securityFilters.blocked_only, }; closeConnectionRef.current = connectSecurityLogs( effectiveFilters, handleSecurityMessage, handleOpen, handleError, handleClose ); } else { // Connect to application logs endpoint const handleLiveMessage = (entry: LiveLogEntry) => { // Use ref to check paused state - avoids WebSocket reconnection when pausing if (isPausedRef.current) return; const displayEntry = toDisplayFromLive(entry); setLogs((prev) => { const updated = [...prev, displayEntry]; return updated.length > maxLogs ? updated.slice(-maxLogs) : updated; }); }; closeConnectionRef.current = connectLiveLogs( filters, handleLiveMessage, handleOpen, handleError, handleClose ); } return () => { if (closeConnectionRef.current) { closeConnectionRef.current(); closeConnectionRef.current = null; } setIsConnected(false); }; // Note: isPaused is intentionally excluded - we use isPausedRef to avoid reconnecting when pausing }, [currentMode, filters, securityFilters, maxLogs, showBlockedOnly]); // Auto-scroll effect useEffect(() => { if (shouldAutoScroll.current && logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } }, [logs]); // Track manual scrolling const handleScroll = () => { if (logContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; // Enable auto-scroll if scrolled to bottom (within 50px threshold) shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50; } }; const handleClear = () => { setLogs([]); }; const handleTogglePause = () => { setIsPaused(!isPaused); }; // Client-side filtering const filteredLogs = logs.filter((log) => { // Text filter - search in message, URI, host, IP if (textFilter) { const searchText = textFilter.toLowerCase(); const matchFields = [ log.message, log.uri, log.host, log.clientIP, log.blockReason, ].filter(Boolean).map(s => s!.toLowerCase()); if (!matchFields.some(field => field.includes(searchText))) { return false; } } // Level filter if (levelFilter && log.level.toLowerCase() !== levelFilter.toLowerCase()) { return false; } // Source filter (security mode only) if (sourceFilter && log.source.toLowerCase() !== sourceFilter.toLowerCase()) { return false; } return true; }); return (
{/* Header with mode toggle and controls */}

{currentMode === 'security' ? 'Security Access Logs' : 'Live Security Logs'}

{isConnected ? 'Connected' : 'Disconnected'} {connectionError && (
{connectionError}
)}
{/* Mode toggle */}
{/* Pause/Resume */} {/* Clear */}
{/* Filters */}
setTextFilter(e.target.value)} className="flex-1 min-w-32 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500" /> {/* Security mode specific filters */} {currentMode === 'security' && ( <> )}
{/* Log display */}
{filteredLogs.length === 0 && (
{logs.length === 0 ? 'No logs yet. Waiting for events...' : 'No logs match the current filters.'}
)} {filteredLogs.map((log, index) => (
{formatTimestamp(log.timestamp)} {/* Source badge for security mode */} {currentMode === 'security' && ( {log.source.toUpperCase()} )} {/* Level badge for application mode */} {currentMode === 'application' && ( {log.level.toUpperCase()} )} {/* Client IP for security logs */} {currentMode === 'security' && log.clientIP && ( {log.clientIP} )} {/* Source tag for application logs */} {currentMode === 'application' && log.source && log.source !== 'app' && ( [{log.source}] )} {/* Message */} {log.message} {/* Block reason badge */} {log.blocked && log.blockReason && ( [{log.blockReason}] )} {/* Status code for security logs */} {currentMode === 'security' && log.status && !log.blocked && ( = 400 ? 'text-red-400' : log.status >= 300 ? 'text-yellow-400' : 'text-green-400'}`}> [{log.status}] )} {/* Duration for security logs */} {currentMode === 'security' && log.duration !== undefined && ( {log.duration < 1 ? `${(log.duration * 1000).toFixed(1)}ms` : `${log.duration.toFixed(2)}s`} )} {/* Additional data */} {log.details && Object.keys(log.details).length > 0 && (
{JSON.stringify(log.details, null, 2)}
)}
))}
{/* Footer with log count */}
Showing {filteredLogs.length} of {logs.length} logs {isPaused && ⏸ Paused}
); }