/** * LogViewer.jsx — System log tail viewer * * Features: * - Subscribes to /rosout (rcl_interfaces/Log) * - Real-time scrolling log output * - Severity-based color coding (DEBUG=grey, INFO=white, WARN=yellow, ERROR=red, FATAL=magenta) * - Filter by severity level * - Filter by node name * - Auto-scroll to latest logs * - Configurable max log history (default 500) */ import { useEffect, useRef, useState } from 'react'; const LOG_LEVELS = { DEBUG: 'DEBUG', INFO: 'INFO', WARN: 'WARN', ERROR: 'ERROR', FATAL: 'FATAL', }; const LOG_LEVEL_VALUES = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40, FATAL: 50, }; const LOG_COLORS = { DEBUG: { bg: 'bg-gray-950', border: 'border-gray-800', text: 'text-gray-500', label: 'text-gray-500' }, INFO: { bg: 'bg-gray-950', border: 'border-gray-800', text: 'text-gray-300', label: 'text-white' }, WARN: { bg: 'bg-gray-950', border: 'border-yellow-900', text: 'text-yellow-400', label: 'text-yellow-500' }, ERROR: { bg: 'bg-gray-950', border: 'border-red-900', text: 'text-red-400', label: 'text-red-500' }, FATAL: { bg: 'bg-gray-950', border: 'border-magenta-900', text: 'text-magenta-400', label: 'text-magenta-500' }, }; const MAX_LOGS = 500; function formatTimestamp(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } function getLevelName(level) { // Convert numeric level to name if (level <= LOG_LEVEL_VALUES.DEBUG) return LOG_LEVELS.DEBUG; if (level <= LOG_LEVEL_VALUES.INFO) return LOG_LEVELS.INFO; if (level <= LOG_LEVEL_VALUES.WARN) return LOG_LEVELS.WARN; if (level <= LOG_LEVEL_VALUES.ERROR) return LOG_LEVELS.ERROR; return LOG_LEVELS.FATAL; } function LogLine({ log, colors }) { return (
{log.level.padEnd(5)} {formatTimestamp(log.timestamp)} [{log.node}] {log.message}
); } export function LogViewer({ subscribe }) { const [logs, setLogs] = useState([]); const [selectedLevels, setSelectedLevels] = useState(new Set(['INFO', 'WARN', 'ERROR', 'FATAL'])); const [nodeFilter, setNodeFilter] = useState(''); const scrollRef = useRef(null); const logIdRef = useRef(0); // Auto-scroll to bottom when new logs arrive useEffect(() => { if (scrollRef.current) { setTimeout(() => { scrollRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' }); }, 0); } }, [logs.length]); // Subscribe to ROS logs useEffect(() => { const unsubscribe = subscribe( '/rosout', 'rcl_interfaces/Log', (msg) => { try { const levelName = getLevelName(msg.level); const logEntry = { id: ++logIdRef.current, timestamp: msg.stamp ? msg.stamp.sec * 1000 + msg.stamp.nanosec / 1000000 : Date.now(), level: levelName, node: msg.name || 'unknown', message: msg.msg || '', file: msg.file || '', function: msg.function || '', line: msg.line || 0, }; setLogs((prev) => [...prev, logEntry].slice(-MAX_LOGS)); } catch (e) { console.error('Error parsing log message:', e); } } ); return unsubscribe; }, [subscribe]); // Toggle level selection const toggleLevel = (level) => { const updated = new Set(selectedLevels); if (updated.has(level)) { updated.delete(level); } else { updated.add(level); } setSelectedLevels(updated); }; // Filter logs based on selected levels and node filter const filteredLogs = logs.filter((log) => { const matchesLevel = selectedLevels.has(log.level); const matchesNode = nodeFilter === '' || log.node.toLowerCase().includes(nodeFilter.toLowerCase()); return matchesLevel && matchesNode; }); const clearLogs = () => { setLogs([]); logIdRef.current = 0; }; return (
{/* Controls */}
SYSTEM LOG VIEWER
{filteredLogs.length} / {logs.length} logs
{/* Severity filter buttons */}
SEVERITY FILTER:
{Object.keys(LOG_COLORS).map((level) => ( ))}
{/* Node filter input */}
NODE FILTER:
setNodeFilter(e.target.value)} className="w-full px-2 py-1.5 text-xs bg-gray-900 border border-gray-800 rounded text-gray-300 focus:outline-none focus:border-cyan-700 placeholder-gray-700" />
{/* Action buttons */}
Auto-scrolls to latest logs
{/* Log viewer area */}
{filteredLogs.length === 0 ? (
No logs to display
Logs from /rosout will appear here
) : ( <> {filteredLogs.map((log) => ( ))}
)}
{/* Topic info */}
Topic: /rosout (rcl_interfaces/Log)
Max History: {MAX_LOGS} entries
Colors: DEBUG=grey | INFO=white | WARN=yellow | ERROR=red | FATAL=magenta
); }