Real-time ROS log stream viewer with: - Subscribes to /rosout (rcl_interfaces/Log) - Severity-based color coding: DEBUG=grey | INFO=white | WARN=yellow | ERROR=red | FATAL=magenta - Filter by severity level (multi-select toggle) - Filter by node name (text input) - Auto-scroll to latest logs - Max 500 logs in history (configurable) - Scrolling log output in monospace font - Proper timestamp formatting (HH:MM:SS) Integrated into MONITORING tab group as 'Logs' tab alongside 'Events'. Follows established React/Tailwind patterns from other dashboard components. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
252 lines
8.2 KiB
JavaScript
252 lines
8.2 KiB
JavaScript
/**
|
|
* 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 (
|
|
<div className={`font-mono text-xs py-1 px-2 border-l-2 ${colors.border} ${colors.bg}`}>
|
|
<div className="flex gap-2 items-start">
|
|
<span className={`font-bold text-xs whitespace-nowrap flex-shrink-0 ${colors.label}`}>
|
|
{log.level.padEnd(5)}
|
|
</span>
|
|
<span className="text-gray-600 whitespace-nowrap flex-shrink-0">
|
|
{formatTimestamp(log.timestamp)}
|
|
</span>
|
|
<span className="text-cyan-600 whitespace-nowrap flex-shrink-0 min-w-32 truncate">
|
|
[{log.node}]
|
|
</span>
|
|
<span className={`${colors.text} flex-1 break-words`}>
|
|
{log.message}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-col h-full space-y-3">
|
|
{/* Controls */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3">
|
|
<div className="flex justify-between items-center flex-wrap gap-2">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
|
SYSTEM LOG VIEWER
|
|
</div>
|
|
<div className="text-gray-600 text-xs">
|
|
{filteredLogs.length} / {logs.length} logs
|
|
</div>
|
|
</div>
|
|
|
|
{/* Severity filter buttons */}
|
|
<div className="space-y-2">
|
|
<div className="text-gray-700 text-xs font-bold">SEVERITY FILTER:</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{Object.keys(LOG_COLORS).map((level) => (
|
|
<button
|
|
key={level}
|
|
onClick={() => toggleLevel(level)}
|
|
className={`px-2 py-1 text-xs font-bold rounded border transition-colors ${
|
|
selectedLevels.has(level)
|
|
? `${LOG_COLORS[level].border} ${LOG_COLORS[level].bg} ${LOG_COLORS[level].label}`
|
|
: 'border-gray-700 bg-gray-900 text-gray-600 hover:text-gray-400'
|
|
}`}
|
|
>
|
|
{level}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Node filter input */}
|
|
<div className="space-y-1">
|
|
<div className="text-gray-700 text-xs font-bold">NODE FILTER:</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Filter by node name..."
|
|
value={nodeFilter}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={clearLogs}
|
|
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-gray-700 bg-gray-900 text-gray-400 hover:text-red-400 hover:border-red-700 transition-colors"
|
|
>
|
|
CLEAR
|
|
</button>
|
|
<div className="text-gray-600 text-xs flex items-center">
|
|
Auto-scrolls to latest logs
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log viewer area */}
|
|
<div className="flex-1 bg-gray-950 rounded-lg border border-cyan-950 overflow-y-auto space-y-0">
|
|
{filteredLogs.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-gray-600">
|
|
<div className="text-center">
|
|
<div className="text-sm mb-2">No logs to display</div>
|
|
<div className="text-xs text-gray-700">
|
|
Logs from /rosout will appear here
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{filteredLogs.map((log) => (
|
|
<LogLine
|
|
key={log.id}
|
|
log={log}
|
|
colors={LOG_COLORS[log.level]}
|
|
/>
|
|
))}
|
|
<div ref={scrollRef} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Topic info */}
|
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Topic:</span>
|
|
<span className="text-gray-500">/rosout (rcl_interfaces/Log)</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Max History:</span>
|
|
<span className="text-gray-500">{MAX_LOGS} entries</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Colors:</span>
|
|
<span className="text-gray-500">DEBUG=grey | INFO=white | WARN=yellow | ERROR=red | FATAL=magenta</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|