diff --git a/src/lib/consoleCapture.ts b/src/lib/consoleCapture.ts new file mode 100644 index 000000000..2646fd114 --- /dev/null +++ b/src/lib/consoleCapture.ts @@ -0,0 +1,82 @@ +export interface ConsoleLogEntry { + timestamp: string; + level: 'LOG' | 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'; + message: string; +} + +const MAX_ENTRIES = 2000; +const entries: ConsoleLogEntry[] = []; + +function serializeArgs(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === 'string') return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(' '); +} + +// JS Date only has ms precision (.234Z). Pad to 6 decimal places to match +// the backend's microsecond format (.234000Z) so timestamps sort and display uniformly. +function timestamp(): string { + return new Date().toISOString().replace(/\.(\d{3})Z$/, '.$1000Z'); +} + +function capture(level: ConsoleLogEntry['level'], args: unknown[]) { + entries.push({ + timestamp: timestamp(), + level, + message: serializeArgs(args), + }); + if (entries.length > MAX_ENTRIES) { + entries.splice(0, entries.length - MAX_ENTRIES); + } +} + +let installed = false; + +export function installConsoleCapture() { + if (installed) return; + installed = true; + + const orig = { + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + debug: console.debug.bind(console), + }; + + console.log = (...args: unknown[]) => { + capture('LOG', args); + orig.log(...args); + }; + console.info = (...args: unknown[]) => { + capture('INFO', args); + orig.info(...args); + }; + console.warn = (...args: unknown[]) => { + capture('WARN', args); + orig.warn(...args); + }; + console.error = (...args: unknown[]) => { + capture('ERROR', args); + orig.error(...args); + }; + console.debug = (...args: unknown[]) => { + capture('DEBUG', args); + orig.debug(...args); + }; +} + +export function getConsoleLogs(): ConsoleLogEntry[] { + return [...entries]; +} + +export function clearConsoleLogs() { + entries.length = 0; +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8ef69fa88..6be0fdddd 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -44,6 +44,7 @@ import { useDefaultOfferExpiry } from '@/hooks/useDefaultOfferExpiry'; import { useErrors } from '@/hooks/useErrors'; import { useScannerOrClipboard } from '@/hooks/useScannerOrClipboard'; import { useWalletConnect } from '@/hooks/useWalletConnect'; +import { getConsoleLogs } from '@/lib/consoleCapture'; import { exportText, ExportType } from '@/lib/exportText'; import { clearState, @@ -652,6 +653,16 @@ function NetworkSettings() { ); } +interface LogEntry { + timestamp: string; + level: string; + message: string; + source: 'backend' | 'frontend'; +} + +const LOG_LINE_REGEX = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(\w+)\s+(.*)$/; + function LogViewer() { const { addError } = useErrors(); @@ -662,11 +673,14 @@ function LogViewer() { const [logLines, setLogLines] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [selectedLevel, setSelectedLevel] = useState('all'); - const [filteredLines, setFilteredLines] = useState([]); + const [selectedSource, setSelectedSource] = useState< + 'all' | 'backend' | 'frontend' + >('all'); + const [filteredEntries, setFilteredEntries] = useState([]); const [isAtBottom, setIsAtBottom] = useState(true); const rowVirtualizer = useVirtualizer({ - count: filteredLines.length, + count: filteredEntries.length, getScrollElement: () => parentRef.current, estimateSize: () => 24, overscan: 5, @@ -682,7 +696,6 @@ function LogViewer() { if (!parentRef.current || !rowVirtualizer) return false; const { scrollTop, clientHeight } = parentRef.current; const scrollHeight = rowVirtualizer.getTotalSize(); - // Consider "at bottom" if within 10px of the bottom return scrollHeight - scrollTop - clientHeight < 10; }, [rowVirtualizer]); @@ -692,10 +705,8 @@ function LogViewer() { const virtualItems = rowVirtualizer.getVirtualItems(); - // Handle scrolling when log changes or new lines are added useEffect(() => { const items = virtualItems; - // Always scroll on initial load of a log, or when at bottom and content changes if ( items.length > 0 && selectedLog && @@ -740,27 +751,56 @@ function LogViewer() { } }, [selectedLog]); - // Filter logs based on search query and selected level useEffect(() => { - const filtered = logLines.filter((line) => { - const matchesSearch = - searchQuery === '' || - line.toLowerCase().includes(searchQuery.toLowerCase()); - - if (!matchesSearch) return false; - - if (selectedLevel === 'all') return true; + const backendEntries: LogEntry[] = logLines.map((line) => { + const match = line.match(LOG_LINE_REGEX); + if (match) { + return { + timestamp: match[1], + level: match[2], + message: match[3], + source: 'backend', + }; + } + return { timestamp: '', level: '', message: line, source: 'backend' }; + }); - const levelMatch = line.match( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(\w+)/, - ); - if (!levelMatch) return false; + const frontendEntries: LogEntry[] = getConsoleLogs().map((entry) => ({ + timestamp: entry.timestamp, + level: entry.level, + message: entry.message, + source: 'frontend', + })); + + const merged = [...backendEntries, ...frontendEntries].sort((a, b) => { + if (!a.timestamp) return 1; + if (!b.timestamp) return -1; + return a.timestamp.localeCompare(b.timestamp); + }); - return levelMatch[1].toLowerCase() === selectedLevel.toLowerCase(); + const filtered = merged.filter((entry) => { + if ( + searchQuery !== '' && + !`${entry.level} ${entry.message}` + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ) { + return false; + } + if ( + selectedLevel !== 'all' && + entry.level.toLowerCase() !== selectedLevel.toLowerCase() + ) { + return false; + } + if (selectedSource !== 'all' && entry.source !== selectedSource) { + return false; + } + return true; }); - setFilteredLines(filtered); - }, [logLines, searchQuery, selectedLevel]); + setFilteredEntries(filtered); + }, [logLines, searchQuery, selectedLevel, selectedSource]); const handleLogChange = (name: string) => { setLogName(name); @@ -768,13 +808,14 @@ function LogViewer() { setSelectedLog(log ?? null); setSearchQuery(''); setSelectedLevel('all'); - setIsAtBottom(true); // Reset isAtBottom when changing logs + setSelectedSource('all'); + setIsAtBottom(true); }; const formatTimestamp = (timestamp: string) => { + if (!timestamp) return ''; try { - const date = new Date(timestamp); - return date.toLocaleTimeString(); + return new Date(timestamp).toLocaleTimeString(); } catch { return timestamp; } @@ -796,9 +837,42 @@ function LogViewer() { }; const handleExport = () => { - if (selectedLog) { - exportText(selectedLog.text, selectedLog.name, ExportType.LOG); - } + if (!selectedLog) return; + + const allBackend: LogEntry[] = logLines.map((line) => { + const match = line.match(LOG_LINE_REGEX); + if (match) { + return { + timestamp: match[1], + level: match[2], + message: match[3], + source: 'backend', + }; + } + return { timestamp: '', level: '', message: line, source: 'backend' }; + }); + + const allFrontend: LogEntry[] = getConsoleLogs().map((entry) => ({ + timestamp: entry.timestamp, + level: entry.level, + message: entry.message, + source: 'frontend', + })); + + const combined = [...allBackend, ...allFrontend] + .sort((a, b) => { + if (!a.timestamp) return 1; + if (!b.timestamp) return -1; + return a.timestamp.localeCompare(b.timestamp); + }) + .map((entry) => { + if (!entry.timestamp) return entry.message; + const tag = entry.source === 'frontend' ? '[UI]' : '[BE]'; + return `${entry.timestamp} ${entry.level.padEnd(5)} ${tag} ${entry.message}`; + }) + .join('\n'); + + exportText(combined, `${selectedLog.name}_combined`, ExportType.LOG); }; return ( @@ -835,7 +909,6 @@ function LogViewer() { > Log Level} /> - All Levels @@ -855,20 +928,46 @@ function LogViewer() { + + - {selectedLog && filteredLines.length === 0 && ( + {selectedLog && filteredEntries.length === 0 && (
No matching log entries found
)} - {selectedLog && filteredLines.length > 0 && ( + {selectedLog && filteredEntries.length > 0 && (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { - const line = filteredLines[virtualRow.index]; - const match = line.match( - /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(\w+)\s+(.*)$/, - ); + const entry = filteredEntries[virtualRow.index]; + const isFrontend = entry.source === 'frontend'; return (
- {match ? ( + {entry.timestamp ? (
- {formatTimestamp(match[1])} + {formatTimestamp(entry.timestamp)}
+ {entry.level.padEnd(5, ' ')} + +
+
+ - {match[2].padEnd(5, ' ')} + {isFrontend ? '[UI]' : '[BE]'}
- {match[3]} + + {entry.message} +
) : (
- {line} + + {entry.message} +
)}
diff --git a/src/setup.ts b/src/setup.ts index c0ebfda25..4f870a5cf 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,3 +1,6 @@ import BigNumber from 'bignumber.js'; +import { installConsoleCapture } from './lib/consoleCapture'; BigNumber.config({ EXPONENTIAL_AT: [-1e9, 1e9] }); + +installConsoleCapture();