feat(webui): system log tail viewer (Issue #275)

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>
This commit is contained in:
sl-webui 2026-03-02 17:28:28 -05:00
parent 0776003dd3
commit 305ce6c971
2 changed files with 253 additions and 0 deletions

View File

@ -270,6 +270,8 @@ export default function App() {
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />} {activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
{activeTab === 'logs' && <LogViewer subscribe={subscribe} />}
{activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />} {activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />}
{activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />} {activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />}

View File

@ -0,0 +1,251 @@
/**
* 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>
);
}