/**
* 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 */}
{/* 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
);
}