Spaces:
Runtime error
Runtime error
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified | '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<string[]>([]) | |
| const [isLoading, setIsLoading] = useState(false) | |
| const [logFilePath, setLogFilePath] = useState<string | null>(null) | |
| const logContainerRef = useRef<HTMLDivElement>(null) | |
| const autoScrollRef = useRef<boolean>(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<LogFilters>) => { | |
| 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 ( | |
| <div className="flex flex-col h-full p-6 space-y-4"> | |
| <div className="border-b border-border pb-4"> | |
| <h1 className="text-3xl font-bold text-foreground">{t('title')}</h1> | |
| <p className="text-muted-foreground mt-2"> | |
| {t('description')} | |
| {logFilePath && ( | |
| <span className="ml-3 font-mono text-xs text-muted-foreground/70">{logFilePath}</span> | |
| )} | |
| </p> | |
| </div> | |
| {/* Filters and Controls */} | |
| <div className="bg-card border border-border rounded-lg p-4"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4"> | |
| {/* Level Filter */} | |
| <div> | |
| <label className="block text-sm font-medium text-foreground mb-2"> | |
| {t('filterLevel')} | |
| </label> | |
| <select | |
| value={logFilters.level || ''} | |
| onChange={(e) => handleFilterChange({ level: e.target.value || undefined })} | |
| className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" | |
| > | |
| <option value="">{t('allLevels')}</option> | |
| <option value="error">{t('levelError')}</option> | |
| <option value="warn">{t('levelWarning')}</option> | |
| <option value="info">{t('levelInfo')}</option> | |
| <option value="debug">{t('levelDebug')}</option> | |
| </select> | |
| </div> | |
| {/* Source Filter */} | |
| <div> | |
| <label className="block text-sm font-medium text-foreground mb-2"> | |
| {t('filterSource')} | |
| </label> | |
| <select | |
| value={logFilters.source || ''} | |
| onChange={(e) => handleFilterChange({ source: e.target.value || undefined })} | |
| className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" | |
| > | |
| <option value="">{t('allSources')}</option> | |
| {availableSources.map((source) => ( | |
| <option key={source} value={source}>{source}</option> | |
| ))} | |
| </select> | |
| </div> | |
| {/* Session Filter */} | |
| <div> | |
| <label className="block text-sm font-medium text-foreground mb-2"> | |
| {t('filterSession')} | |
| </label> | |
| <input | |
| type="text" | |
| value={logFilters.session || ''} | |
| onChange={(e) => 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" | |
| /> | |
| </div> | |
| {/* Search Filter */} | |
| <div> | |
| <label className="block text-sm font-medium text-foreground mb-2"> | |
| {t('filterSearch')} | |
| </label> | |
| <input | |
| type="text" | |
| value={logFilters.search || ''} | |
| onChange={(e) => 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" | |
| /> | |
| </div> | |
| {/* Controls */} | |
| <div className="flex items-end space-x-2"> | |
| <Button | |
| onClick={() => setIsAutoScroll(!isAutoScroll)} | |
| variant={isAutoScroll ? 'success' : 'outline'} | |
| > | |
| {isAutoScroll ? t('auto') : t('manual')} | |
| </Button> | |
| <Button | |
| onClick={handleScrollToBottom} | |
| className="bg-blue-500/20 text-blue-400 border border-blue-500/30 hover:bg-blue-500/30" | |
| > | |
| {t('bottom')} | |
| </Button> | |
| </div> | |
| {/* Export & Clear */} | |
| <div className="flex items-end space-x-2"> | |
| <Button | |
| onClick={handleExportText} | |
| disabled={filteredLogs.length === 0} | |
| className="bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/30 disabled:opacity-40" | |
| > | |
| {t('exportLog')} | |
| </Button> | |
| <Button | |
| onClick={handleExportJson} | |
| disabled={filteredLogs.length === 0} | |
| className="bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/30 disabled:opacity-40" | |
| > | |
| {t('exportJson')} | |
| </Button> | |
| <Button | |
| onClick={clearLogs} | |
| variant="destructive" | |
| > | |
| {t('clear')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Log Stats */} | |
| <div className="flex items-center justify-between text-sm text-muted-foreground"> | |
| <div className="flex items-center gap-3"> | |
| <span>{t('showing', { filtered: filteredLogs.length, total: logs.length })}</span> | |
| {isBufferFull && ( | |
| <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/15 text-yellow-400 border border-yellow-500/25"> | |
| {t('bufferFull', { max: MAX_LOG_BUFFER })} | |
| </span> | |
| )} | |
| </div> | |
| <div> | |
| {t('autoScroll')}: {isAutoScroll ? t('on') : t('off')} • | |
| {t('lastUpdated')}: {logs.length > 0 ? new Date(logs[0]?.timestamp).toLocaleTimeString() : t('never')} | |
| </div> | |
| </div> | |
| {/* Log Display */} | |
| <div className="flex-1 bg-card border border-border rounded-lg overflow-hidden"> | |
| <div | |
| ref={logContainerRef} | |
| className="h-full overflow-auto p-4 space-y-2 font-mono text-sm" | |
| > | |
| {isLoading ? ( | |
| <Loader variant="panel" label="Loading logs" /> | |
| ) : filteredLogs.length === 0 ? ( | |
| <div className="flex items-center justify-center h-32 text-muted-foreground"> | |
| {t('noLogs')} | |
| </div> | |
| ) : ( | |
| filteredLogs.map((log) => ( | |
| <div | |
| key={log.id} | |
| className={`border-l-4 pl-4 py-2 rounded-r-md ${getLogLevelBg(log.level)}`} | |
| > | |
| <div className="flex items-start justify-between"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center space-x-2 text-xs"> | |
| <span className="text-muted-foreground"> | |
| {new Date(log.timestamp).toLocaleTimeString()} | |
| </span> | |
| <span className={`font-medium uppercase ${getLogLevelColor(log.level)}`}> | |
| {log.level} | |
| </span> | |
| <span className="text-muted-foreground"> | |
| [{log.source}] | |
| </span> | |
| {log.session && ( | |
| <span className="text-muted-foreground"> | |
| session:{log.session} | |
| </span> | |
| )} | |
| </div> | |
| <div className="mt-1 text-foreground break-words"> | |
| {log.message} | |
| </div> | |
| {log.data && ( | |
| <details className="mt-2"> | |
| <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground"> | |
| {t('additionalData')} | |
| </summary> | |
| <pre className="mt-1 text-xs text-muted-foreground overflow-auto"> | |
| {JSON.stringify(log.data, null, 2)} | |
| </pre> | |
| </details> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |