'use client' import { useState, useEffect, useRef, useCallback } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Loader } from '@/components/ui/loader' import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' import { createClientLogger } from '@/lib/client-logger' const log = createClientLogger('LogViewer') const MAX_LOG_BUFFER = 1000 interface LogFilters { level?: string source?: string search?: string session?: string } function downloadFile(content: string, filename: string, mime: string) { const blob = new Blob([content], { type: mime }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.click() URL.revokeObjectURL(url) } export function LogViewerPanel() { const t = useTranslations('logViewer') const { logs, logFilters, setLogFilters, clearLogs, addLog } = useMissionControl() const [isAutoScroll, setIsAutoScroll] = useState(true) const [availableSources, setAvailableSources] = useState([]) const [isLoading, setIsLoading] = useState(false) const [logFilePath, setLogFilePath] = useState(null) const logContainerRef = useRef(null) const autoScrollRef = useRef(true) const logsRef = useRef(logs) const logFiltersRef = useRef(logFilters) const isBufferFull = logs.length >= MAX_LOG_BUFFER // Update ref when autoScroll state changes useEffect(() => { autoScrollRef.current = isAutoScroll }, [isAutoScroll]) // Keep refs in sync so callbacks don't need `logs` / `logFilters` deps. useEffect(() => { logsRef.current = logs }, [logs]) useEffect(() => { logFiltersRef.current = logFilters }, [logFilters]) const loadLogs = useCallback(async (tail = false) => { log.debug(`Loading logs (tail=${tail})`) setIsLoading(!tail) // Only show loading for initial load, not for tailing try { const currentFilters = logFiltersRef.current const currentLogs = logsRef.current const params = new URLSearchParams({ action: tail ? 'tail' : 'recent', limit: '200', ...(currentFilters.level && { level: currentFilters.level }), ...(currentFilters.source && { source: currentFilters.source }), ...(currentFilters.search && { search: currentFilters.search }), ...(currentFilters.session && { session: currentFilters.session }), ...(tail && currentLogs.length > 0 && { since: currentLogs[0]?.timestamp.toString() }) }) log.debug(`Fetching /api/logs?${params}`) const response = await fetch(`/api/logs?${params}`) const data = await response.json() log.debug(`Received ${data.logs?.length || 0} logs from API`) if (data.logs && data.logs.length > 0) { if (tail) { // Add new logs for tail mode - prepend to existing logs let newLogsAdded = 0 const existingIds = new Set((currentLogs || []).map((l: any) => l?.id).filter(Boolean)) data.logs.reverse().forEach((entry: any) => { if (existingIds.has(entry?.id)) return addLog(entry) newLogsAdded++ }) log.debug(`Added ${newLogsAdded} new logs (tail mode)`) } else { // Replace logs for initial load or refresh log.debug(`Clearing existing logs and loading ${data.logs.length} logs`) clearLogs() // Clear existing logs data.logs.reverse().forEach((entry: any) => { addLog(entry) }) log.debug(`Successfully added ${data.logs.length} logs to store`) } } else { log.debug('No logs received from API') } } catch (error) { log.error('Failed to load logs:', error) } finally { setIsLoading(false) } }, [addLog, clearLogs]) const loadSources = useCallback(async () => { try { const response = await fetch('/api/logs?action=sources') const data = await response.json() setAvailableSources(data.sources || []) } catch (error) { log.error('Failed to load log sources:', error) } }, []) // Try to fetch log file path from gateway status const loadLogFilePath = useCallback(async () => { try { const response = await fetch('/api/status') const data = await response.json() const path = data?.config?.logFile || data?.logFile || null setLogFilePath(path) } catch { // Gateway may not expose this — silently ignore } }, []) // Load initial logs and sources useEffect(() => { log.debug('Initial load started') loadLogs() loadSources() loadLogFilePath() }, [loadLogs, loadSources, loadLogFilePath]) // Smart polling for log tailing (10s, visibility-aware, logs mostly come via WS) const pollLogs = useCallback(() => { if (autoScrollRef.current && !isLoading) { loadLogs(true) // tail mode } }, [isLoading, loadLogs]) useSmartPoll(pollLogs, 30000, { pauseWhenConnected: true }) // Auto-scroll to bottom when new logs arrive useEffect(() => { if (isAutoScroll && logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight } }, [logs, isAutoScroll]) const handleFilterChange = (newFilters: Partial) => { setLogFilters(newFilters) // Reload logs with new filters setTimeout(() => loadLogs(), 100) } const handleScrollToBottom = () => { if (logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight } } const getLogLevelColor = (level: string) => { switch (level.toLowerCase()) { case 'error': return 'text-red-400' case 'warn': return 'text-yellow-400' case 'info': return 'text-blue-400' case 'debug': return 'text-muted-foreground' default: return 'text-foreground' } } const getLogLevelBg = (level: string) => { switch (level.toLowerCase()) { case 'error': return 'bg-red-500/10 border-red-500/20' case 'warn': return 'bg-yellow-500/10 border-yellow-500/20' case 'info': return 'bg-blue-500/10 border-blue-500/20' case 'debug': return 'bg-gray-500/10 border-gray-500/20' default: return 'bg-secondary border-border' } } const filteredLogs = logs.filter(entry => { if (logFilters.level && entry.level !== logFilters.level) return false if (logFilters.source && entry.source !== logFilters.source) return false if (logFilters.search && !entry.message.toLowerCase().includes(logFilters.search.toLowerCase())) return false if (logFilters.session && (!entry.session || !entry.session.includes(logFilters.session))) return false return true }) const handleExportText = useCallback(() => { const lines = filteredLogs.map(entry => { const ts = new Date(entry.timestamp).toISOString() return `[${ts}] [${entry.level.toUpperCase()}] [${entry.source}] ${entry.message}` }) const filename = `logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.log` downloadFile(lines.join('\n'), filename, 'text/plain') }, [filteredLogs]) const handleExportJson = useCallback(() => { const filename = `logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json` downloadFile(JSON.stringify(filteredLogs, null, 2), filename, 'application/json') }, [filteredLogs]) // Debug logging log.debug(`Store has ${logs.length} logs, filtered to ${filteredLogs.length}`) return (

{t('title')}

{t('description')} {logFilePath && ( {logFilePath} )}

{/* Filters and Controls */}
{/* Level Filter */}
{/* Source Filter */}
{/* Session Filter */}
handleFilterChange({ session: e.target.value || undefined })} placeholder={t('sessionPlaceholder')} className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground placeholder-muted-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" />
{/* Search Filter */}
handleFilterChange({ search: e.target.value || undefined })} placeholder={t('searchPlaceholder')} className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground placeholder-muted-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" />
{/* Controls */}
{/* Export & Clear */}
{/* Log Stats */}
{t('showing', { filtered: filteredLogs.length, total: logs.length })} {isBufferFull && ( {t('bufferFull', { max: MAX_LOG_BUFFER })} )}
{t('autoScroll')}: {isAutoScroll ? t('on') : t('off')} • {t('lastUpdated')}: {logs.length > 0 ? new Date(logs[0]?.timestamp).toLocaleTimeString() : t('never')}
{/* Log Display */}
{isLoading ? ( ) : filteredLogs.length === 0 ? (
{t('noLogs')}
) : ( filteredLogs.map((log) => (
{new Date(log.timestamp).toLocaleTimeString()} {log.level} [{log.source}] {log.session && ( session:{log.session} )}
{log.message}
{log.data && (
{t('additionalData')}
                          {JSON.stringify(log.data, null, 2)}
                        
)}
)) )}
) }